Performance Penalty of Python Exceptions

The other day at work, a colleague and I were discussing the relative merits of indicating an error in Python by raising an exception or by returning a null/empty/false value. It boiled down to this implementation:

def get_something(url, what):
    response = requests.get(url, params={'id': what})
    if not requests.body:
        return None
    return _parse_body(response.body)

versus this:

def get_something(url, what):
    response = requests.get(url, params={'id': what})
    if not requests.body:
        raise SomeError('empty response from %s' % url)
    return _parse_body(response.body)

(Yes, I'm ignoring network and HTTP errors.) Assume the service behind url is not supposed to return an empty response, and we want to notice if it does. In both cases, it's easy for the caller to detect this condition. But which way is better?

My colleague argued for returning None (or an empty dict) on the grounds that “if is faster than try”. Hmmm. I've always assumed that raising an exception has a considerable performance penalty, but have never considered that there might be a penalty to catching an exception that isn't there. In other words: exceptions are supposed to be rare, so it's pointless to worry about the performance hit from raising one. But is there a performance hit from simply enclosing code in a try/except?

Let's try it and see.

No errors

First, let's implement a query function that never fails, just to establish a baseline. This serves two purposes: it lets us see how much faster it is to completely ignore errors, and it shows the overhead of an if or try where we never take the error path.

# v1: cannot fail
def get_something_1():
    if random.random() < 0:
        raise AssertionError()
    return 42

Now we'll call this cannot-fail query three ways:

# v1: ignore errors
def try_something_1():
    return get_something()

# v2: detect errors with "if"
def try_something_2():
    something = get_something()
    if something is None:
        pass
    return something

# v3: detect errors with "try/except"
def try_something_3():
    try:
        return get_something()
    except SomeError:
        pass

Note that I'm deliberately doing nothing on the error path. I'm not interested in the overhead of handling the error, just in the overhead of try/except vs. if.

I ran this code using the timeit module from Python's standard library, using 5 repetitions of 5m runs:

get_something_1, try_something_1: best of 5: 1.742 s     # no errors, ignore errors
get_something_1, try_something_2: best of 5: 1.813 s     # no errors, detect with if
get_something_1, try_something_3: best of 5: 1.709 s     # no errors, detect with try/except

Divide by 5m and you can see we're looking at around 0.35 µs per call. These particular numbers are from Python 2.7.9 under Ubuntu 15.04 with a 3.33 GHz Intel Core i5 CPU. Python 3 was a bit faster, but the overall pattern is the same.

So, using if is slightly slower than try/except when we never hit the error path. Ignoring errors entirely is about the same as try/except.

Conclusion so far: if is not faster than try/except. If anything, it's a bit slower. That might change if we reordered the code so the happy path comes first (I didn't try it).

Rare errors

A more realistic scenario is when your query occasionally fails. Here are two versions of the same query function, both rigged to fail 0.1% of the time:

# v2: rare failure (0.1% probability) by returning None
def get_something_2():
    if random.random() < 0.001:
        return None
    return 42

# v3: rare failure by raising SomeError
def get_something_3():
    if random.random() < 0.001:
        raise SomeError()
    return 42

It no longer makes sense to exercise these versions with try_something_1(), which ignores errors. In fact, we can only exercise get_something_2() with try_something_2(), and similarly for get_something_3(). So now we're measuring the overhead of both reporting an error and detecting it:

get_something_2, try_something_2: best of 5: 1.771 s     # rare errors, detect with if
get_something_3, try_something_3: best of 5: 1.646 s     # rare errors, detect with try/except

The system runs at about the same speed when 1 in 1000 queries follows a different code path. Surprisingly, try/except is still winning by a hair. If there is an overhead to raise, it's not noticeable yet.

Conclusion: when errors are infrequent, if is not faster than try/except. Again, reordering the code might matter.

Frequent errors

Finally, let's modify the query function so that errors are frequent:

# v4: frequent failure (30% probability) by returning None
def get_something_4():
    if random.random() < 0.3:
        return None
    return 42

# v5: frequent failure by raising SomeError
def get_something_5():
    if random.random() < 0.3:
        raise SomeError()
    return 42

Since the error-reporting semantics are the same, we can stick with try_something_2() and try_something_3() to exercise these two. Results:

get_something_4, try_something_2: best of 5: 1.806 s     # frequent errors, detect with if
get_something_5, try_something_3: best of 5: 3.195 s     # frequent errors, detect with try/except

Ah-ha! Now we're on to something. The version using if stayed about the same, but raising an exception on 30% of queries caused a dramatic slowdown. So raise does have noticeable overhead if it happens often enough.

Source code

See http://www.gerg.ca/blog/attachments/try-except-speed.py.

Conclusion

It's safe to say that if is definitely not faster than try. That's clearly a false assumption, which is what I was expecting to find.

In fact, if might be slower than try, although I suspect minor tweaks to the code (keep the happy path adjacent in memory) might make a difference. It's worth trying.

And raise does have a noticeable overhead, but only if errors are frequent. Another interesting experiment to do with this code is find out how frequent errors have to be before the overhead of raise is noticeable.

That said, if 30% of some operation results in an error, you probably have bigger problems than the overhead of raising an exception. You might want to look into more reliable infrastructure. Just sayin'.

Author: Greg Ward
Published on: Sep 18, 2015, 12:06:42 PM - Modified on: Sep 19, 2015, 4:40:36 PM
Permalink - Source code