honnibal.dev

Exception handlers are not general-purpose conditionals

2026-02-03 · 6 minute read

There’s some bad advice about Python style that’s been circling in the community for a long time, and it shows up in LLM-generated code now. If you’re using LLMs to help you program, it’s extremely important to have good habits dialled in and to recognise problematic patterns. It’s therefore important to finally clear this up.

Possible race condition
if path.exists():
f = path.open()
Catch on failure
try:
f = path.open()
except FileNotFoundError:
f = None
No pre-check API
Catch API-specific errors
try:
data = json.loads(text)
except JSONDecodeError:
data = None
Possible by-catch
try:
value = d[key]
except KeyError:
value = None
Tight conditional
if key in d:
value = d[key]
Possible by-catch
try:
x = foo.bar
except AttributeError:
x = None
Tight conditional
if hasattr(foo, "bar"):
x = foo.bar

There’s been a longstanding debate about the ideas “look before you leap” (LBYL) and “easier to ask forgiveness than permission” (EAFP). This LBYL vs EAFP framing sets people wrong from the start. It’s really not an issue of style preference. In most contexts only one is correct, and if you use the wrong one for the situation, you’ll introduce subtle bugs.

Here’s the rule.

If you’re dealing with state you don’t own and can’t lock — e.g. network calls, system calls, file system operations, concurrent data access etc — you can’t correctly check preconditions using an if or while, due to race conditions. By the time you enter the conditional block, something might have created the file, deleted the remote record you want to work on, etc. In such situations you need to use try/except.

You also need to use try/except if the function you’re calling is unable or unwilling to provide an inexpensive way to check whether the input you’re passing in is valid. For instance, a JSON parser probably doesn’t want to give you a function to check whether some string is valid JSON, it’ll ask you to just suck it and see.

On the other hand, if you’re trying to express a simple condition over local values, it’s usually very difficult to get the condition exactly right with a try/except, especially if you’re catching built-in errors such as KeyError, AttributeError etc.

Here’s an example of the sort of mistake that’s made its way into Python books for years:

def process_list(some_dict: Mappable, potential_keys: list):
output = []
for key in potential_keys:
try:
value = some_dict[key]
except KeyError:
continue
output.append(do_something(value))
return output

If you’re having trouble seeing the fault, think about the potential values of some_dict and key. The “happy path” the author might have in mind for the function is a built-in dict and a str key, but the types say the function should work with a much wider range of inputs. Suppose some_dict is a built-in dict, but the keys are some custom object, and there’s a bug in the __hash__() method that causes a KeyError to be raised. I could put the same object in the keys and dict, and the function would pass over the value as though the key weren’t present. Or the some_dict object could be a user-defined type, and it could have a different bug that somehow results in an unexpected KeyError.

No Python function can meaningfully promise not to raise a built-in exception. The built-in exceptions are emergency exits; they’re what you get when bugs happen. The only thing you have to do about this is nothing. If there’s a fire way down the corridor, that’s not at all your problem — unless you’ve gone and blocked the exit, because you’re just hanging out in the fire escape like it’s a normal thoroughfare.

In order to write reliable programs that are more than a few hundred lines long, you can’t view correctness as some emergent property of the program as a whole. You need to write functions that are correct in themselves. A lot of exception handler bugs are bugs that will only affect the execution of your program in the presence of other bugs. This is why I like the fire escape analogy. Stacking old furniture in the fire escape is no problem, until one day it is.

It’s very unlikely that your tests will identify these errors. If you write something like this:

try:
some_function(some_object.some_attribute)
except AttributeError:
return None

You can’t expect your tests to monkey-patch in a version of some_function that has a bug that results in an AttributeError. Nor can you expect your tests to try an object where .some_attribute is a property that calls some other function that has a bug that results in an AttributeError. All these unexpected paths through the code will go undetected, potentially for years, maybe throughout the lifespan of the program.

If you’re relying heavily on LLM generated code, you’re probably also leaning heavily on generated unit tests, so you should pay particular attention to ways functions which pass all their tests and appear to work individually can fail in combination. If the LLM hallucinates some function that doesn’t exist, PyRight will catch that for me before the tests even run, so I don’t worry much about that type of error. What worries me are classes that with lots of stateful attributes, data being passed around in untyped dictionaries, race conditions, and — more than anything else — exception handling.

Catching built-in exceptions is by far the number one way that LLMs try to cheat me by producing code that only pretends to work. I strongly suspect there’s reward hacking at work in this. The solution “just silence all the errors lol” is pretty easy to reach in the fitness landscape, especially since unideal try/except practice is pretty common in Python code. If there’s a reinforcement learning objective to get tests passing, it makes sense that it would settle into this solution.