Exceptions in Python: catching, raising, customizing, and logging

We all do our best to make sure that our code handles edge cases and doesn’t throw errors, but since we write software for humans, sometimes it’s unavoidable. Let’s talk about some of the different ways we can handle this when this happens, using, of course, cats. Cats throw exceptions all the time, right?

(The examples in this post use exception-catching in lieu of any other form of input validation - this is not typically recommended, but just used in order to demonstrate the different ways exceptions can be caught, raised, created, and logged).

Uncaught exceptions are what we typically think of when we’re talking about exceptions. They’re things we didn’t know to expect, so we didn’t handle them. Let’s say we need to stock up on cat food, and need to know how much food we have for each cat, so we write a simple function to do that:

def food_per_cat(cat_food_cups, num_cats):
    return cat_food_cups / num_cats

Now, let’s say we find ourselves in the unusual position of having cat food on hand, but no cats:

>>> food_per_cat(2, 0)
File "<stdin>", line 2, in food_per_cat
ZeroDivisionError: division by zero

This is an uncaught exception. Our code threw an error, and we didn’t do anything to catch or log it, so we just see the stack trace (I just ran this from my shell, if it were run from a file, the stack trace would include which line in the file).

Uncaught exceptions should typically be avoided. If you’re building a web app, your user will be presented with a server error instead of a useful message, making it hard for them to know what to do next. If you’re scripting, the user has to parse the stack trace to figure out what might have gone wrong instead of being presented with a useful error message.

If your code has validation for all the edge cases you think could reasonably happen, sometimes uncaught exceptions can be useful - it means something truly unexpected happened, and you will likely need to make a code change to handle this new edge case. If you generically catch all exceptions regardless of what caused them, logging and monitoring systems may not be able to capture these errors, meaning engineers won’t have the information they need to fix things that may be causing the end user a headache unnecessarily. Sometimes we need users to let us know what edge cases they are hitting that we should have accounted for but didn’t — while errors aren’t an ideal way for this to happen, in very rare cases, they can serve that purpose.

Ok, let’s make our function a little more interesting. Instead of passing in the number of cats that we have, we need to make an API call to see how many cats there are. Now, we know things can go wrong when we make API calls, so we can put this API call inside a try/except block so that if something does go wrong, the user won’t get an application error.

def food_per_cat(cat_food_cups):
   try:
       cats_response = requests.post('https://example.com/cats')
       num_cats = len(json.loads(cats_response.data))
       return cat_food_cups / num_cats
   except Exception as e:
       return 0

Exception is a Python built-in and is the most general exception class that exists. It will catch anything at all that could go wrong, be that a KeyError or a ValueError or anything else you can imagine. This kind of exception catching is useful when you’re not sure what might go wrong, but you don’t, under any scenario, want the user to be impacted.

We’ll take a quick detour at this point to something that often goes hand-in-hand with exceptions: logging. The good thing about catching exceptions is that your user doesn’t have to see the end result of them - the user doesn’t necessarily need to know that they happened at all. But that doesn’t mean no one should know they happened! When something goes wrong, we usually want to log that so that developers can debug and fix the issue. Let’s make some tweaks to our function above:

import logging

logger = logging.getLogger(__name__)

def food_per_cat(cat_food_cups):
   try:
       cats_response = requests.post('https://example.com/cats')
       num_cats = len(json.loads(cats_response.data))
       return cat_food_cups / num_cats
   except Exception as e:
       logger.exception(f"Uh oh, something went wrong! exc={e}"))
       return 0

logging is another Python built-in - if you’re not familiar with it, I’d recommend starting with the documentation linked here.

That log line is the only addition to our function from the previous section, but you’ll notice something interesting about it: we’ve used the instance of the exception itself (e) in our message. Instances of exceptions come built-in with a __str__ method on them, which means they can be passed into strings for interpolation, the way we’ve done above, and they’ll render as a human-readable value.

Exceptions are logged at the error level, but unlike error logs, will always include the stack trace, even when not explicitly passed in. I have a personal preference for always using the exception logger over the error one - your logging handler and formatter will respond the same way to them, but will include more information.

We talked above about how to catch exceptions. But we did it in such a way that we don’t know the difference between the various types of exceptions that might be thrown. Let’s fix that.

import logging

logger = logging.getLogger(__name__)

def food_per_cat(cat_food_cups):
   try:
       cats_response = requests.post('https://example.com/cats')
       num_cats = len(json.loads(cats_response.data))
       return cat_food_cups / num_cats
   except ZeroDivisionError:
       logging.warning(f"Looks like you don't have any cats!")
       return cat_food_cups
   except Exception as e:
       logging.exception(f"Uh oh, something went wrong! exc={e}"))
       return 0

In this example, you’ll see we’re specifically catching the ZeroDivisionError that can happen if num_cats is 0 - we’re acknowledging that this might happen, handing it separately, and then still going on to catch other exceptions generically, in case something else unexpected goes wrong.

In addition to catching certain kinds of exceptions specifically, the way we did above, you can define your own custom exceptions that can be caught just like any built-in one. Their defining characteristic is that they should inherit from Exception or something else that inherits from Exception. They don’t necessarily need to define any behavior. Like so:

class CatException(Exception):
    pass

But how does this help us? Let’s look at an example below 👇

Exceptions are bad. Why would we intentionally want to raise an exception? Well, let’s do a little restructuring and use our custom exception above and see what happens:

class CatException(Exception):
    pass

def food_per_cat(cat_food_cups):
    try:
        num_cats = len(get_cats())
        return cat_food_cups / num_cats
    except CatException:
        logger.exception("Something went wrong when getting cats")
    except ZeroDivisioinError:
        logging.warning(f"Looks like you don't have any cats!")
    except Exception as e:
        logger.exception(f"Uh oh, something went wrong! exc={e}")
    finally:
        return cat_food_cups


def get_cats():
    try:
        cats_response = requests.post('https://example.com/cats')
        return json.loads(cats_response.data)
    except:
        raise CatException

Sure, this example is a little contrived, and, as with the examples throughout this post, there are probably better ways to validate return values and user inputs than catching exceptions all over the place, it does demonstrate the uses of these things.

In our get_cats function, we explicitly raised our custom defined CatException if something went wrong with the API call, so that we could catch that specific error in the place where it was used, in addition to the other things we know might go wrong.

You’ll notice one other new piece of functionality in this most recent example: finally. After all our exceptions have been thrown and caught, we need something to happen at the end: enter finally! This will be the final thing that happens as part of the try clause, and allows us to return something from the function (or do whatever else we might need to do), even if everything else went wrong along the way.