Home About

Pythonic or Performant?

Aniket

February 13, 2025

In Python circles, you’ll frequently come across the term “Pythonic”. While there’s no authoritative definition of it, the generally accepted meaning is:

Code that is clear, succint and leverages Python’s builtins to maintain readability, even when expressing complicated algorithms

In a performance sensitive codebase though, writing code that follows traditionally Pythonic patterns can lead to some surprising slowness. Let’s have a closer look.

Pattern: Ask for forgiveness not permission

In this pattern (AFNP), you perform whatever operation you need, assuming that it’ll succeed. If it fails, you’re expected to handle the exception. This pattern is in contrast to “look before you leap” (LBYL), where you first check whether the operation should even be attempted.

Let’s take the quintessential example of this pattern - looking up a key in a dict, where the key may not exist in the dict. There are two ways of doing this:

# look before you leap
def lookup(d, k):
    if k in d:
        return d[k]
# ask for forgiveness not permission
def lookup(d, k):
    try:
        return d[k]
    except KeyError:
        pass

Which one do you think is faster? Just eyeballing the code, we’d reason that the “ask for forgiveness” version must be faster, since it only does a single dict access, whereas the “look before you leap” version performs two dict accesses every time.

You’d be quite right with that.. but that might be an incomplete answer. Ideally, we’d want to measure the performance here under two circumstances: (a) When the key exists in the dict and (b) when it does not.

Benchmarks

So, let’s write up 4 micro-benchmarks, we have the two patterns, and we’d like to check their performance under the two conditions mentioned above.

You can find the full code here: Link.

It’s a simple setup, we use the timeit module to run the above lookup functions 100 million times. Let’s look at the results:

Benchmark results
Benchmark Pattern Key exists in dict Execution Time (sec)
afnp_exist.py AFNP Yes 8.56
lbyl_exist.py LBYL Yes 10.53
afnp_no_exist.py AFNP No 22.73
lbyl_no_exist.py LBYL No 8.36

Well, the first three numbers are in-line with our hypothesis. Why is the afnp_no_exist.py case so slow though? You could hook up perf and dig deeper, but spoiler alert: it is because raising exceptions in Python is costly business. I’ll repeat it for emphasis:

Raising exceptions in Python is costly business.

Unconvinced? Have a look under the hood: ceval.c#do_raise. That’s a lot of code that needs to be run - many objects get allocated, various data structures get updated, etc1.

In short, if your codebase is performance sensitive, it might help to avoid raising exceptions. Before you jump ahead and start removing this pattern from your code though, have a look at the Python documentation, which mentions that the LBYL method can lead to data races in multi-threaded code.


  1. To be fair, exceptions are fairly expensive even in compiled languages. I believe that’s partly why Rust doesn’t have exception handling at all, and GCC/Clang have -fno-exceptions.↩︎


Copyright 2025