Skip to content

Handling Exceptions Gracefully in Python to Prevent Program Crashes

Updated: at 03:34 AM

In Python, errors or exceptions that occur during program execution are handled through the use of try and except blocks. Without proper exception handling, your Python program can crash and terminate abruptly. Learning how to gracefully handle exceptions is an essential skill for writing robust and production-ready Python code.

This comprehensive guide will teach you techniques, best practices, and example code for handling exceptions elegantly in Python. You will learn how to:

Proper exception handling helps make Python programs more fault-tolerant, stable, and production-ready. Let’s get started!

Table of Contents

Open Table of Contents

Introduction to Exceptions in Python

An exception in Python is an error that occurs during execution of a program. When an exception is not handled, the program will terminate and crash.

Some common exceptions in Python include:

To handle these errors gracefully and prevent crashes, you need to anticipate exceptions that may occur in your program logic and handle them appropriately using try and except blocks.

The try and except Blocks

The try and except blocks in Python are used to catch and handle exceptions elegantly. The standard syntax is:

try:
   # Normal code here
   pass

except ExceptionType:
   # Exception handling code here
   pass

The try block contains code that might raise an exception. If an exception occurs, the rest of the try block is skipped and the except block is executed to handle the exception.

Let’s look at an example that handles a ZeroDivisionError:

try:
  num = 10/0 # Generates ZeroDivisionError
except ZeroDivisionError:
  print("Cannot divide by zero!")

If no exception occurs in the try block, the except block is skipped. You can have multiple except blocks to handle different exception types.

The except block can also optionally specify an exception instance variable after the exception type to get information about the error:

try:
  # Exception causing code
except ValueError as e:
  print("Invalid value:", e)

Matching Specific Exception Types

It is good practice to only handle exceptions you anticipate may occur in the try block. Use specific exceptions instead of the bare except: clause to avoid accidentally catching unrelated errors.

For example:

try:
  int('abc')
except ValueError:
  print("Could not convert string to int!")

Catching the general Exception type should be avoided:

# Bad practice!
try:
  int('abc')
except:
  print("Error occurred!")

The bare except: will also catch syntax errors, keyboard interrupts, and system exits unrelated to the try block code.

You can specify multiple except blocks to handle different specific exception types in the proper order:

try:
  # Code that may throw IOError or KeyError
except IOError:
  # Handle IOError
except KeyError:
  # Handle KeyError

This allows handling exceptions appropriately based on their type.

Handling Multiple Exceptions with One except Block

You can use tuple syntax to handle multiple exception types with a single except block:

try:
  # Code
except (TypeError, ValueError, ZeroDivisionError):
  # Handle all 3 exception types

Grouping related exceptions into one except block allows minimizing repetitive code.

You can also group all built-in exceptions as a catch-all block with BaseException:

try:
  # Code
except BaseException:
  # Handle any built-in exceptions

But this should generally be avoided as it also hides programmer errors and obscures the actual exception.

Printing Debug Information on Exceptions

It is useful to print debug information when handling exceptions to log details about the error.

We can access the exception instance in the except block to print additional debug info:

try:
  # Exception raising code
except ValueError as e:
  print("Exception:", repr(e))
  print("Line number:", e.__traceback__.tb_lineno)

The repr() function prints a detailed representation of the exception instance. We can also access the line number where the exception occurred using traceback module attributes.

Logging exception messages and stack traces helps with debugging and understanding error causes during development.

The else Clause with try/except

The try/except block can also have an optional else clause that runs only when no exceptions occur:

try:
  num = int(input("Enter a number: "))
except ValueError:
  print("That was not a number!")
else:
  print("Valid number entered!")

The code in else executes if the try block completes without exceptions. This avoids needing to set a success flag.

else helps separate the happy path logic from exception handling.

Cleaning Up Resources with finally

The finally clause can be added at the end to execute code that must run after the try and except blocks, even if an unhandled exception occurs.

This is commonly used to release external resources such as files, network connections, and locks:

f = open("file.txt")
try:
  # Code reading from file
finally:
  f.close() # Runs always after try/except

Any exceptions that occur in finally will override exceptions in the try block. Resources should be closed or released in reverse order of acquisition in finally.

Defining Custom Exception Classes

When errors are specific to your application logic, it is useful to define custom exception classes that extend the built-in Exception class:

class CustomError(Exception):
  pass

raise CustomError("My specific error!")

Custom exceptions let you handle domain or logic-specific errors differently than generic built-in exceptions.

Custom exception classes can accept initialization parameters to include details about the specific error:

class InvalidUsernameError(Exception):
  def __init__(self, message, invalid_username):
    self.message = message
    self.invalid_username = invalid_username

raise InvalidUsernameError("Invalid username", "bogususername")

The exception instance can be accessed in the except block to retrieve attributes about the error.

Raising Exceptions Manually

Exceptions can be intentionally raised in your code to signal error conditions:

def validate_age(age):
  if age < 0:
    raise ValueError("Age cannot be negative!")
  if age > 200:
    raise ValueError("Age seems invalid!")

validate_age(-10) # Raises ValueError

This is better than returning error result codes because the exception will propagate up the call stack until handled. Exceptions should be raised immediately when the error condition is detected.

Reraising Exceptions

In some cases, you may want to catch an exception but still let it bubble up the stack to be handled at a higher level. This can be done by re-raising the exception inside the except block:

try:
  # Code that raises KeyError
  pass
except KeyError as e:
  # Log error details
  raise e

This allows handling the exception while still sending it up to a broader scope. All traceback info is maintained.

Reraising is useful for logging or purposes before allowing the normal handling to proceed. Use raise by itself with no arguments in the except block to achieve this.

Chaining Exceptions

When re-raising exceptions, you can also wrap it in a new exception to add additional context. The original exception is still accessible from the new one:

try:
  # Code raising OSError
except OSError as e:
  raise RuntimeError("Error opening file!") from e

Now the exception reraised is RuntimeError, but OSError exception info is maintained. The outer exception is chained to the inner one.

Chaining exceptions wraps lower-level exceptions with higher-level ones and preserves error context across layers.

Printing the Full Stack Trace

To print the entire traceback stack of an exception as it propagates up, use traceback module:

import traceback

try:
  # Code raising exception
except:
  traceback.print_exc()

For logs, the stack trace helps pinpoint the source of error by showing pathway of originating function calls.

You can also print just the exception class and message without the full stack trace using print(e) inside the except block.

Using Assertions to Fail Fast

Assertions can be used to validate assumptions and fail fast with exceptions:

x = 10
assert x >= 0, "x cannot be negative!"

x = -5
assert x >= 0, "x cannot be negative!" # AssertionError raised

Assertions raise AssertionError if the condition evaluates to false and should be used for validating program state, not user input.

Failing fast via assertions for invalid conditions helps locate bugs quicker.

Context Managers and the with Statement

Context managers allow you to handle entry and exit of a block to manage resources apart from main program logic. Exceptions can be handled inside the context manager:

with open("file.txt") as f:
  try:
    data = f.read()
  except:
    print("Error reading file!")
  finally:
    f.close()

The underlying file object is automatically closed when exiting the context. Using with statements helps decouple resource handling from business logic.

Best Practices for Exception Handling

Follow these best practices for exception handling:

Proper exception handling makes Python code more robust, fault-tolerant, and production-ready.

Handling exceptions helps isolate and manage errors gracefully so programs can recover and continue execution. By anticipating and handling exceptions elegantly, you can write Python code that is resilient in the face of failures.

Example Code

Here is some example Python code illustrating proper exception handling best practices:

# Custom exception
class InvalidUserIdException(Exception):
  """Custom exception raised when invalid user ID"""
  pass

# Assertions
def validate_user(user_id):
  assert type(user_id) is int, "User ID must be integer!"
  assert user_id > 0, "User ID must be positive!"

# Handling specific exception
try:
  num = 10 / 0
except ZeroDivisionError as e:
  print("Cannot divide by zero!", e)

# Chaining exceptions
try:
  raise IOError("File not found!")
except IOError as e:
  raise RuntimeError("Failed to open file") from e

# Reraising exception
try:
  int("abc")
except ValueError as e:
  print("Could not convert to integer!")
  raise

# Print stack trace
try:
  5/0
except:
  traceback.print_exc()

# Context manager
with open("file.txt") as f:
  try:
    data = f.read()
  except:
    print("Failed to read file!")

# Raise custom exception
raise InvalidUserIdException("User ID cannot be negative")

Properly handling exceptions in Python code using these techniques will make programs more failure tolerant and stable.

Conclusion

Exception handling is an essential skill for writing robust Python code that can handle errors gracefully instead of crashing. By leveraging try/except blocks, specific exceptions, custom classes, reraising, chained exceptions, context managers, assertions and best practices, you can isolate faults and prevent program crashes.

Make exception handling a priority in your Python projects. Anticipate the errors that can occur, document expected exceptions, and handle them elegantly. This will lead to more stable, fault-tolerant programs that deliver better experiences for users and developers alike.

Gracefully handling exceptions helps Python programs run reliably and prevents bugs from terminating your software in production. Focus on handling errors properly and you’ll build more resilient Python systems.