Sin number 4: Exception frenzy

Python’s Exception class is probably one of the most abused and misused features of the language. It’s also something that no developer can escape from.

Whence the temptation?

An Exception is a pretty natural element of any language. In all languages one will always have some need for a runtime error. In case of Python, most runtime errors inherit from the Exception class. Though technically not a base class (for that would be BaseException), it should be treated like a base class and it often isn’t.

One of the most common forms of antipatterns with exceptions in Python are:

  • catching exceptions instead of checking values
  • using Exception class for user-defined exceptions directly
  • abusing try/except clause

Why is this a sin?

Proper patterns enforced when using exceptions mean that your code is more stable and more readable at the same time. Novice developers will often abuse try/except and raise just because it’s easy to catch something wrong or cause something wrong to happen.

How to recognize a sinner?

This is by far the most common antipattern I’ve seen, which almost always appears when working with dictionaries:

try:
    print(some_dict["sth"])
except Exception:
    pass

There are two problems with this solution. First, what’s caught is a general Exception class. This means that if we place any other code into the try block that throws any other type of exception, we will not be able to detect the problem:

try:
    print(some_dict["sth"])
    int("I cannot be converted to an integer")
except Exception:
    pass

The code above will run without any issues reported! To fix this we could hone in on the concrete type of an exception that the invalid key access throws for a dictionary:

try:
    print(some_dict["sth"])
    int("I cannot be converted to an integer")
except KeyError:
    pass

Now, we’re catching only the KeyErrors. That’s good because this means that our ridiculous int cast in the example above will crash the application and we’ll be forced to fix the issue. I guess you can now see how dangerous operating try/except can be.

Exceptions can be too general

This also explains why we should create subclasses from Exception instead of raising Exception itself everywhere in our program. Most exceptions are not equivalent and it will be easier for the consumers of your Python package to handle failure cases. So doing this would be fairly bad:

x = 0
if type(x) is not int:
    raise Exception("x should be int")
if x > 0:
    raise Exception("x should not be greater than 0")

How to repent?

So let’s fix the issues that we’ve outlined in the previous section. We’ve already covered catching KeyError but frankly if you are able to avoid try/except, do it. In case of dictionary key access, it’s pretty easy:

if "sth" in some_dict.keys():
    print(some_dict["sth"])
else:
    print("No sth key in dict")

I like to call this pattern: check for values instead of catching errors. You should always try to cover all possibilities when working with something that might fail. Languages with strong functional paradigms like Rust will usually enforce this with structural pattern matching. Python 3.10 has introduced pattern matching, so if you’re a fan of functional, you could express the same like this:

match some_dict:
    case {"sth": x}:
        print(x)
    case _:
        print("No sth key in dict")

I find pattern matching an absolutely brilliant option. Notice that in the example above I didn’t have to use .keys() and in at any point, I just told the interpreter to pattern-match a sub-dict of the some_dict and place the value into a variable named x.

Probably the cleanest solution is to use collections.defaultdict:

from collections import defaultdict
some_defaultdict = defaultdict(lambda: None, some_dict)
print(some_dict["sth"] or "No key sth in dict")

defaultdict behaves just like a normal dictionary but when it’s created you give it a function that will make a default value when a particular key is missing. I usually like to use it with lambda: None so that subsequently I can use the something or default idiom.

👉 Avoid try/except when you can unless you really have to handle an error and there is no way to check for a value without resorting to error handling.

Making exceptions more precise

So going one step further if you’re creating your own exception cases you should also make subclasses from the Exception class for each family of errors. The example that we used before in the previous section to demonstrate this should ideally be refactored as:

x = 0
if type(x) is not int:
    raise TypeError("x should be int")
if x > 0:
    raise ValueError("x should not be greater than 0")

TypeError and ValueError are already built-in error classes in Python which are a great option for generic errors when a wrong value or an improper type has been provided. But consider the following example:

if obj_a == obj_b:
    raise Exception("Objects should not be identical")

This one doesn’t really fit with ValueError or TypeError since it’s more about equality of two custom objects (assumming obj_a and obj_b exist). Instead we should define a custom Exception type and raise it:

class ObjectsIdenticalException(Exception):
    """Raised when two objects are identical"""
    pass


if obj_a == obj_b:
    raise ObjectsIdenticalException("Objects should not be identical")

This way any consumer of your package can decide to catch ObjectsIdenticalException just as we have caught KeyError for the dictionary keys instead of catching the most general Exception.