Skip to content

In-Depth Guide to Exceptions and Error Handling in Python

Updated: at 05:45 AM

Exceptions are an important aspect of programming in Python that every developer needs to understand. At its core, an exception is an error that occurs during the execution of a program. Unlike syntax errors that prevent a program from running, exceptions arise at runtime and cause the program to stop its normal execution. Handling exceptions properly is essential to make Python programs robust and prevent crashes.

This comprehensive guide will explain what exceptions are, how they work, and how to handle them properly in Python. We will cover the following topics in depth with example code snippets:

Table of Contents

Open Table of Contents

What are Exceptions?

Exceptions, as the name suggests, are events that occur when some kind of exceptional condition arises in a program. In Python, exceptions are errors that are detected during execution and result in the program stopping normal execution.

Some common examples of exceptions:

When an exception occurs, Python generates an exception object that contains information about the error. This exception object is passed to the exception handler if the programmer has set one up using try and except blocks (covered later). Otherwise, the default exception handler is called which stops the execution of the program and prints an error traceback.

The point to note is that the program does not stop execution immediately on an error. The exception mechanism allows you to intercept errors and handle them gracefully so the program can continue normal execution. This makes programs more robust and fault-tolerant.

The Exception Hierarchy

All built-in exceptions in Python inherit from a base class called BaseException. This forms a hierarchy of exception classes that inherit from each other.

The highest-level distinction is between exceptions that inherit from Exception and those that inherit directly from BaseException.

For most practical purposes, you need only concern yourself with subclasses of Exception. The subclasses of BaseException are either internal to Python or deprecated.

Here is a simplified view of the exception inheritance hierarchy:

BaseException
+-- SystemExit
+-- KeyboardInterrupt
+-- GeneratorExit
+-- Exception
     +-- StopIteration
     +-- StopAsyncIteration
     +-- ArithmeticError
     |    +-- FloatingPointError
     |    +-- OverflowError
     |    +-- ZeroDivisionError
     +-- AssertionError
     +-- AttributeError
     +-- BufferError
     +-- EOFError
     +-- ImportError
     +-- LookupError
     |    +-- IndexError
     |    +-- KeyError
     +-- MemoryError
     +-- NameError
     |    +-- UnboundLocalError
     +-- OSError
     |    +-- BlockingIOError
     |    +-- ChildProcessError
     |    +-- ConnectionError
     |    |    +-- BrokenPipeError
     |    |    +-- ConnectionAbortedError
     |    |    +-- ConnectionRefusedError
     |    |    +-- ConnectionResetError
     |    +-- FileExistsError
     |    +-- FileNotFoundError
     |    +-- InterruptedError
     |    +-- IsADirectoryError
     |    +-- NotADirectoryError
     |    +-- PermissionError
     |    +-- ProcessLookupError
     |    +-- TimeoutError
     +-- ReferenceError
     +-- RuntimeError
     |    +-- NotImplementedError
     |    +-- RecursionError
     +-- SyntaxError
     |    +-- IndentationError
     |         +-- TabError
     +-- SystemError
     +-- TypeError
     +-- ValueError
     |    +-- UnicodeError
     |         +-- UnicodeDecodeError
     |         +-- UnicodeEncodeError
     |         +-- UnicodeTranslateError
     +-- Warning
          +-- DeprecationWarning
          +-- PendingDeprecationWarning
          +-- RuntimeWarning
          +-- SyntaxWarning
          +-- UserWarning
          +-- FutureWarning
          +-- ImportWarning
          +-- UnicodeWarning
          +-- BytesWarning
          +-- ResourceWarning

This hierarchy helps in catching specific or general exceptions based on the level of granularity you need in handling different errors.

For example, a ZeroDivisionError is more specific than an ArithmeticError, but catching the ArithmeticError will catch both kinds of errors.

Raising Exceptions

Python raises exceptions automatically when errors occur at runtime. But you can also manually raise exceptions if needed.

Raising an exception is done by using the raise statement.

raise ExceptionClass(args)

The ExceptionClass is the type of exception you want to raise, and args is optional information on the exception being raised.

For example:

# Raise a generic Exception
raise Exception('An error occurred')

# Raise a TypeError
raise TypeError('Invalid type for variable a')

# Raise a custom exception
class MyCustomError(Exception):
    pass

raise MyCustomError('Custom error message')

You can raise exceptions in situations where you want to force a certain condition to be met or explicitly fail if something unexpected occurs.

Some common use cases for raising exceptions:

Raising an exception will cause the current execution to stop and the exception will propagate up the call stack until it is caught or causes the program to exit.

Handling Exceptions

Now that we know how exceptions work in Python, let’s see how to handle them effectively.

The primary way to handle exceptions in Python is to use try and except blocks. This allows you to intercept exceptions and take corrective actions instead of letting them crash the program.

The try and except Block

The basic syntax for using try and except blocks is:

try:
   # Normal code here
   pass

except:
   # Execute this code when an exception occurs
   pass

The code inside the try block is executed normally until an exception occurs. Once an exception is encountered, the remaining code in the try block is skipped and the except block is executed to handle the exception.

For example:

try:
  file = open('data.txt')
  data = file.read()
  print(data)

except:
  print('Could not open file')

This avoids the program crashing if the file cannot be opened by intercepting the FileNotFoundError and handling it gracefully.

You can have multiple except blocks to handle different types of exceptions in different ways. The exception will be matched from top to bottom and the first block that matches will be executed.

A common practice is to make the last except block a bare except to catch any unanticipated exceptions.

Catching Specific Exception Types

A better way is to catch and handle specific exception types instead of generic Exception class.

For example:

try:
  # Code here

except TypeError:
  # Handle TypeError

except ValueError:
  # Handle ValueError

except OSError:
  # Handle OSError

except:
  # Handle any other exceptions

This allows you to selectively handle different exception conditions.

You can even store the exception instance in a variable to get attributes like the error message:

try:
  # Code

except Exception as ex:
  print('Error occurred:', ex.args)

Multiple exceptions can be handled together by specifying them in a tuple:

except (TypeError, ValueError):
  # Handle TypeError and ValueError

The else Clause

You can use an optional else clause after all except blocks. The code in else will only execute if no exceptions were raised in the try block:

try:
  # Code
except:
  # Exception occurred

else:
  # No exceptions
  pass

This can be useful to run code that should only execute if everything in the try block succeeded.

finally Clause

The finally clause can be used at the end to define a block of code to execute regardless of whether an exception occurred.

For example:

try:
   file = open('data.txt')
   # Process file

except OSError:
   print('Could not open file')

finally:
   file.close() # Close file even if exception

The finally block runs after any except blocks but before control reaches the rest of the program outside the try statement.

This is commonly used to perform cleanup actions like closing files, releasing resources etc.

The with Statement

A simpler way to ensure resources like files are properly handled is to use the with statement.

The advantage of using a with block for resource handling is that it neatly encapsulates setup and teardown actions within a single block.

The syntax for with is:

with EXPRESSION as VAR:
  # Code here

Where:

Internally, the EXPRESSION is evaluated to obtain a resource known as a context manager. The context manager handles opening and closing the resource.

For example, to safely open and close a file:

with open('data.txt') as file:
  data = file.read()

# File closed automatically here

This avoids having to explicitly close the file even if exceptions occur. The with block allows you to get the benefits of using try/finally in a simpler way.

Custom Exceptions

In addition to built-in exceptions, you can define your own custom exception classes in Python to indicate specific error conditions relevant to your program.

Creating a custom exception is as simple as defining a new class inheriting from Exception:

class MyCustomError(Exception):
  pass

raise MyCustomError('Custom exception raised')

You can define additional attributes and methods to provide more context on the exception:

class InvalidInputError(Exception):
  """Exception raised for invalid input
  Attributes:
      expression -- input expression that caused the error
      message -- explanation of the error
  """

  def __init__(self, expression, message):
      self.expression = expression
      self.message = message

raise InvalidInputError('2 + cat','Invalid expression: '+expression)

Now users of your library can specifically catch this InvalidInputError instead of generic exceptions.

Defining custom exceptions is useful in making your code more readable, maintainable and user-friendly.

Best Practices for Exceptions

Here are some best practices to keep in mind when dealing with exceptions in Python:

Conclusion

Understanding exceptions is crucial to developing robust programs in Python. Exceptions provide an organized way to react to unexpected runtime problems and prevent the program from terminating abruptly.

Using the exception handling tools like try, except, finally and with allows you to intercept errors and recover gracefully instead of letting them crash your program. Defining custom exceptions also improves the usability of your code.

By following best practices in exception handling, you can write Python code that is resilient in the face of failures and provides clear error reporting. This results in applications that remain stable and reliable even when things go wrong.