Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 65

Targeted Exception Handling

Recommended resources

Introduction

In our last lesson, we focused on raising specific built-in exceptions like ValueError and TypeError to enforce a function's "contract" with its caller. This is how you signal that an input is of the wrong type or has an inappropriate value.

This lesson addresses the other side of the transaction: how to catch exceptions correctly. Your goal is to master the learning outcome: Catch specific exceptions narrowly and avoid using bare except: or overly broad except Exception:.

Coming from a Java background, you might be familiar with the temptation to write a broad catch (Exception e) block to handle all potential problems at once. In Python, this is a significant anti-pattern that can hide serious bugs and even make your program unresponsive. We will dissect why this is the case and establish the Pythonic practice of catching only the specific errors you can meaningfully handle.


The Dangers of Catching Too Broadly

A try...except block is a tool for handling expected, predictable errors—like a file not being found or a network connection timing out. It is not a tool for papering over programming mistakes. The most common "Java-ism" to unlearn is catching exceptions too broadly, which comes in two dangerous forms: the bare except: and the slightly-less-dangerous except Exception:.

Let's look at why these are so problematic.


Anti-Pattern #1: The Unforgivable Bare except:

A try...except: block with no specified exception type is the most dangerous construct in Python's error handling. It catches everything. This might sound useful, but it includes exceptions that are critical for controlling your program's execution.

To understand why, you need to know about Python's exception hierarchy. All exceptions inherit from BaseException. However, most programmatic and user-defined errors inherit from Exception. A few critical, system-level exceptions inherit directly from BaseException, bypassing Exception. These include:

  • SystemExit: Raised by sys.exit() to terminate the program.
  • KeyboardInterrupt: Raised when the user presses Ctrl+C.
  • GeneratorExit: Used internally with generators.

A bare except: catches these too, which means it can prevent your program from exiting.

Using except: is a HUGE mistake

The following video from mCoding demonstrates vividly why a bare except: is a huge mistake. Pay close attention to the difference between BaseException and Exception and what happens when the code tries to catch a KeyboardInterrupt.

Watch the segment from 01:44 to 05:02. The video shows a program that becomes impossible to stop with Ctrl+C because of an overly broad exception handler. This is the primary reason to never use a bare except:.

As you saw, swallowing KeyboardInterrupt makes your program profoundly user-unfriendly. The same goes for SystemExit. If part of your code calls sys.exit(), a bare except: will prevent the exit and allow the program to continue in what might be an inconsistent state.

Rule: Never use a bare except:. There is no common, valid use case for it in application code.


Anti-Pattern #2: The Deceptive except Exception:

Using except Exception: is an improvement, as it will not catch SystemExit or KeyboardInterrupt. However, it is still far too broad and is considered a significant code smell.

The problem is that except Exception: catches every non-system-exiting exception. This includes all the common programming errors that should crash your program during development so you can fix them:

  • NameError (e.g., a typo in a variable name)
  • TypeError (e.g., trying to add a string to an integer)
  • AttributeError (e.g., calling a method that doesn't exist)

When you catch Exception, you are effectively telling your program: "If any of these bugs happen, don't crash. Just run this error-handling code instead." This makes debugging incredibly difficult, as the original error and its traceback are hidden. You are treating a bug as a recoverable runtime error.

python try except and best practices (beginner - intermediate) anthony explains #359

This video from anthonywritescode explains this exact problem: catching Exception can mask typos and other programming errors, making your code harder to debug.

Watch the clip from 04:57 to 06:07. The presenter shows how a simple NameError caused by a typo would be silently caught by an except Exception: block, hiding the bug.

Rule: Avoid except Exception:. Only use it at the highest level of your application (e.g., the main entry point or a web request handler) to log a catastrophic, unexpected failure before shutting down or returning a generic error message.


The Pythonic Solution: Catch Specific, Expected Exceptions

The correct approach is to catch only the narrowest possible set of exceptions that you can genuinely anticipate and handle. This makes your code more robust, readable, and self-documenting.

Best Practices for Exception Handling in Python Mind Map
This mind map summarizes key best practices. For this lesson, we are focused on 'Use Specific Exceptions' and 'Avoid Bare Excepts'.

The principle is simple: look at the code inside your try block and ask, "What could predictably go wrong here that I can recover from?" Then, write an except block for each of those specific cases.

Exception Handling Best Practices

The following article from Real Python provides a definitive guide on this topic, with a perfect 'bad vs. good' example. It's a clear demonstration of refactoring away from a broad except to specific handlers.

First, read the bullet point list under 'Best Practices', focusing on the one that says 'Catch the narrowest exception you can handle.' Then, carefully study the code examples under '🔴 Avoid this:' and '✅ Favor this:'. Notice how the bad example uses except Exception twice, hiding all errors, while the good example handles FileNotFoundError and json.JSONDecodeError specifically.

Let's dissect the load_config example from the article.

🔴 Bad Code:

import json

def load_config(path):
    try:
        with open(path, encoding="utf-8") as config:
            data = json.load(config)
    except Exception: # What went wrong? A typo? File not found? Bad JSON?
        return {} # Silently return an empty dict, hiding the problem.
    return data

This is a ticking time bomb. If settings.json is missing, it returns {}. If settings.json contains invalid JSON, it returns {}. If you have a typo like jsn.load(config), it will catch the NameError and return {}! You have no way of knowing what the actual problem is.

✅ Good Code:

import json

class ConfigError(Exception):
    """Raised when issues occur with the config file."""

def load_config(path):
    try:
        with open(path, encoding="utf-8") as config:
            data = json.load(config)
    except FileNotFoundError as error:



        # We expected this might happen and can handle it.
        raise ConfigError(f"Config file not found: {path}") from error
    except json.JSONDecodeError as error:



        # We also expected this might happen.
        raise ConfigError(f"Invalid JSON: {path}") from error
    



    # Any other exception, like a NameError, will not be caught and will crash,
    # correctly pointing out the bug.
    return data

This version is robust and clear. It explicitly anticipates two potential failures (FileNotFoundError and json.JSONDecodeError) and handles them by raising a custom, more informative error. Any other unexpected exception will correctly crash the program, providing a full traceback to the developer.


Exercise: Refactor to Catch Specific Exceptions

Here is a function that calculates the ratio of two numbers specified in a dictionary. It uses a broad except Exception: block. Your task is to refactor it to catch specific, expected exceptions.

Original Code:

def calculate_ratio(data: dict, numerator_key: str, denominator_key: str) -> float:
    """Calculates the ratio of two values from a dictionary."""
    try:
        numerator = data[numerator_key]
        denominator = data[denominator_key]
        ratio = numerator / denominator
        print(f"Calculated ratio: {ratio}")
        return ratio
    except Exception as e:
        print(f"An error occurred: {e}")
        return 0.0 # Return a default value

What specific exceptions should you anticipate inside the try block?

  1. The keys (numerator_key or denominator_key) might not exist in the dictionary.
  2. The values associated with the keys might not be numbers.
  3. The denominator might be zero.

Refactor the function to include specific except blocks for KeyError, TypeError, and ZeroDivisionError.

Solution

Here is the refactored, Pythonic version of the function:

def calculate_ratio_refactored(data: dict, numerator_key: str, denominator_key: str):
    """Calculates the ratio of two values from a dictionary, handling specific errors."""
    try:
        numerator = data[numerator_key]
        denominator = data[denominator_key]
        ratio = numerator / denominator
        print(f"Calculated ratio: {ratio}")
        return ratio
    except KeyError as e:



        # Handle the case where a key is missing.
        print(f"Error: Missing key in data dictionary: {e}")



        # Depending on requirements, you might raise a more specific error or return.
    except TypeError:



        # Handle the case where values are not numbers.
        print(f"Error: Values for '{numerator_key}' or '{denominator_key}' are not numbers.")
    except ZeroDivisionError:



        # Handle division by zero.
        print(f"Error: Denominator '{denominator_key}' cannot be zero.")
    



    # Notice we don't return anything here; the function implicitly returns None on error.
    # Alternatively, you could re-raise a custom exception.
    # Unexpected errors (e.g., an underlying bug) are NOT caught.

This version is far superior because it distinguishes between different failure modes, providing clear feedback for each. It no longer uses a default return value that could mask problems and will let any truly unexpected errors crash the program.


Conclusion

You have now learned one of the most critical lessons in Pythonic error handling: be specific in what you catch. This practice is a cornerstone of writing robust, maintainable, and debuggable code.

Key Takeaways:

  • Never use a bare except:. It makes your program unresponsive to Ctrl+C and sys.exit().
  • Avoid broad except Exception: clauses. They hide programming errors like NameError and TypeError, making debugging a nightmare.
  • Catch the narrowest exceptions you can anticipate and handle. This makes your control flow explicit and prevents you from masking bugs. Let all unexpected exceptions bubble up and fail loudly.

In this lesson and the last, we've seen how to raise specific exceptions and catch specific exceptions. But what if you catch a specific low-level error (like FileNotFoundError) and want to raise a more meaningful, high-level application error (like ConfigError) without losing the original debugging information? Our next lesson will cover exception chaining with raise ... from, a powerful feature that does exactly that.