I pulled your last PR this morning. The config loader. Line 47 jumped out at me immediately.
That's the dictionary lookup. I check if the key exists before I access it. I have been doing that since day one — it's defensive programming. Nothing can KeyError if you check first.
Your code works. I am not here to tell you it is broken. I want to show you what it costs. Here is what you wrote:
# Your version — LBYL: Look Before You Leap
if 'timeout' in config:
timeout = config['timeout']
else:
timeout = 30And here is what a senior Python developer would write:
# EAFP: Easier to Ask Forgiveness than Permission
try:
timeout = config['timeout']
except KeyError:
timeout = 30Same result. Different cost.
I don't see the problem. I check existence, then I retrieve. That is two steps but both are O(1). Where is the hidden cost?
The in check is not free. key in dict hashes the key, finds the bucket, and checks for the key — exactly the same work as dict[key]. Your version does that work twice every time the key exists. Once to check, once to get. The EAFP version does it once. Exceptions in Python are cheap when they do not fire. For keys that exist 95% of the time, you are paying a double-lookup tax on 95% of accesses.
Wait — so Python does a full hash lookup for the in check? I always assumed checking was cheaper than accessing. Like a guard that costs almost nothing.
That is a reasonable intuition from languages where exceptions are expensive, so checking first saves you. Python inverted this. The exception mechanism is fast, and the common-case access is the thing to optimize. Guido made this choice deliberately — it is in the official glossary. The philosophy is EAFP: try the operation, handle the miss. It is not about being reckless. It is about not doing the same work twice.
So LBYL is correct in C or Java where exceptions are genuinely heavy. In Python the cost model is different, and writing LBYL here is importing an assumption that does not apply. I have been paying a tax designed for a different language.
That is exactly the diagnosis. And there is a second problem beyond cost — the LBYL version has a race condition on files:
# LBYL on a file — has a TOCTOU bug
import os
if os.path.exists(filepath):
with open(filepath) as f: # file could be deleted here
data = f.read()
else:
data = ""
# EAFP — no race window
try:
with open(filepath) as f:
data = f.read()
except FileNotFoundError:
data = ""Between the exists check and the open, another process can delete the file. Your check said "safe to proceed" and by the time you proceeded, the world had changed. EAFP eliminates that entire window because the check and the operation are the same step.
TOCTOU — time of check to time of use. I have heard that term in security contexts. I did not realize it applied to ordinary file operations in Python. My config loader reads files in three places using the LBYL pattern.
Now you know what to fix. But before you refactor everything to try/except, I want to show you when LBYL is the right call. Exceptions communicate "this is unusual." If failure is a normal part of your loop — filtering, type checking, validation — wrapping every item in try/except lies to the reader:
# LBYL is correct here — failure is expected, not exceptional
for item in user_inputs:
if isinstance(item, str) and item.isdigit():
process(int(item))
# Half these items won't be digits — that's normal filtering, not an exceptionIf you wrapped that in except ValueError, you are using exception machinery to do ordinary conditional logic. That is confusing and slightly slower.
The rule is about frequency and semantics. If the failure case is rare and unexpected — use EAFP. If the failure case is a normal branch — use LBYL. Exceptions should mean something exceptional is happening, not just "this item did not match."
You are stating it more precisely than most people do after a year of Python. One more pattern before the challenge — the .get() shortcut. For the simple "value or default" case, there is a third option faster than both:
# LBYL — two lookups on hit
timeout = config['timeout'] if 'timeout' in config else 30
# EAFP — one lookup always
try:
timeout = config['timeout']
except KeyError:
timeout = 30
# Pythonic shorthand — single lookup, clearest intent
timeout = config.get('timeout', 30)For the simple default case, .get() wins on every axis. Reserve full try/except for when the except block does something meaningful — logging, fallback chains, re-raising with context.
I have eleven if key in config checks in that loader. All of them return a default when the key is missing. Every single one of them is a .get() call waiting to happen. I have been writing five lines where Python gives me one.
That is the refactor. Scan for if key in d: value = d[key] patterns, replace simple defaults with .get(), and replace the ones with complex fallback logic with try/except. When Amir sees it in review, he will not comment on the lookup style — because there will not be anything to comment on. The code will say exactly what it means.
I am going to run that refactor today. And now I have a name for the pattern — EAFP — so I can explain in the PR why I made the change instead of just saying it looked cleaner.
Python's preference for EAFP over LBYL is not aesthetic — it emerges from how CPython implements dictionary lookups and exception handling at the bytecode level.
Why LBYL costs more than it looks. A Python dictionary stores key-value pairs in a hash table. When you write if key in d, CPython executes LOAD_FAST (load the dict), LOAD_CONST (load the key), and CONTAINS_OP — which calls dict.__contains__, which hashes the key, computes the bucket index, and scans for a matching entry. When you then write d[key], CPython executes the same hash computation, the same bucket index, the same scan. Two full table lookups for one conceptual operation. The EAFP version (d[key] inside try/except) performs the lookup once and installs a handler before entry — a cheap frame-level operation that costs nothing if no exception fires.
The exception fast path. Python exceptions are implemented as C setjmp/longjmp pairs. The try: block calls setjmp to record the current execution context and installs the handler in the frame's exception table. This setup costs a few nanoseconds per try block, regardless of what happens inside. When no exception fires, the except block is never evaluated and the handler is discarded with minimal overhead. The cost of try/except is dominated by the exception case, not the common case. This is why EAFP is cheaper than it looks: the overhead is proportional to the miss rate, not the hit rate.
TOCTOU and concurrent state. Beyond performance, EAFP eliminates a class of correctness bugs. Any check-then-act sequence has a window between the check and the act during which shared state can change. os.path.exists(path) followed by open(path) is the classic example: another process can unlink the file in the nanoseconds between the check and the open. EAFP collapses the window to zero because the check and the act are the same operation. This matters in multi-threaded code, in concurrent async code, and anywhere shared filesystem state is involved.
When to override the preference. EAFP is the right default for operations where failure is rare and the check and the operation are the same cost. It is the wrong choice when failure is frequent (a filtering loop where half the items fail) or when the check is genuinely cheaper than the operation (validating a string format before an expensive parse). The rule is not "always EAFP" — it is "use EAFP when exceptions are genuinely exceptional." The semantic weight of a try/except block signals to readers: "something unusual can happen here." When unusual is actually usual, that signal is misleading and the code is harder to reason about.
Sign up to write and run code in this lesson.
I pulled your last PR this morning. The config loader. Line 47 jumped out at me immediately.
That's the dictionary lookup. I check if the key exists before I access it. I have been doing that since day one — it's defensive programming. Nothing can KeyError if you check first.
Your code works. I am not here to tell you it is broken. I want to show you what it costs. Here is what you wrote:
# Your version — LBYL: Look Before You Leap
if 'timeout' in config:
timeout = config['timeout']
else:
timeout = 30And here is what a senior Python developer would write:
# EAFP: Easier to Ask Forgiveness than Permission
try:
timeout = config['timeout']
except KeyError:
timeout = 30Same result. Different cost.
I don't see the problem. I check existence, then I retrieve. That is two steps but both are O(1). Where is the hidden cost?
The in check is not free. key in dict hashes the key, finds the bucket, and checks for the key — exactly the same work as dict[key]. Your version does that work twice every time the key exists. Once to check, once to get. The EAFP version does it once. Exceptions in Python are cheap when they do not fire. For keys that exist 95% of the time, you are paying a double-lookup tax on 95% of accesses.
Wait — so Python does a full hash lookup for the in check? I always assumed checking was cheaper than accessing. Like a guard that costs almost nothing.
That is a reasonable intuition from languages where exceptions are expensive, so checking first saves you. Python inverted this. The exception mechanism is fast, and the common-case access is the thing to optimize. Guido made this choice deliberately — it is in the official glossary. The philosophy is EAFP: try the operation, handle the miss. It is not about being reckless. It is about not doing the same work twice.
So LBYL is correct in C or Java where exceptions are genuinely heavy. In Python the cost model is different, and writing LBYL here is importing an assumption that does not apply. I have been paying a tax designed for a different language.
That is exactly the diagnosis. And there is a second problem beyond cost — the LBYL version has a race condition on files:
# LBYL on a file — has a TOCTOU bug
import os
if os.path.exists(filepath):
with open(filepath) as f: # file could be deleted here
data = f.read()
else:
data = ""
# EAFP — no race window
try:
with open(filepath) as f:
data = f.read()
except FileNotFoundError:
data = ""Between the exists check and the open, another process can delete the file. Your check said "safe to proceed" and by the time you proceeded, the world had changed. EAFP eliminates that entire window because the check and the operation are the same step.
TOCTOU — time of check to time of use. I have heard that term in security contexts. I did not realize it applied to ordinary file operations in Python. My config loader reads files in three places using the LBYL pattern.
Now you know what to fix. But before you refactor everything to try/except, I want to show you when LBYL is the right call. Exceptions communicate "this is unusual." If failure is a normal part of your loop — filtering, type checking, validation — wrapping every item in try/except lies to the reader:
# LBYL is correct here — failure is expected, not exceptional
for item in user_inputs:
if isinstance(item, str) and item.isdigit():
process(int(item))
# Half these items won't be digits — that's normal filtering, not an exceptionIf you wrapped that in except ValueError, you are using exception machinery to do ordinary conditional logic. That is confusing and slightly slower.
The rule is about frequency and semantics. If the failure case is rare and unexpected — use EAFP. If the failure case is a normal branch — use LBYL. Exceptions should mean something exceptional is happening, not just "this item did not match."
You are stating it more precisely than most people do after a year of Python. One more pattern before the challenge — the .get() shortcut. For the simple "value or default" case, there is a third option faster than both:
# LBYL — two lookups on hit
timeout = config['timeout'] if 'timeout' in config else 30
# EAFP — one lookup always
try:
timeout = config['timeout']
except KeyError:
timeout = 30
# Pythonic shorthand — single lookup, clearest intent
timeout = config.get('timeout', 30)For the simple default case, .get() wins on every axis. Reserve full try/except for when the except block does something meaningful — logging, fallback chains, re-raising with context.
I have eleven if key in config checks in that loader. All of them return a default when the key is missing. Every single one of them is a .get() call waiting to happen. I have been writing five lines where Python gives me one.
That is the refactor. Scan for if key in d: value = d[key] patterns, replace simple defaults with .get(), and replace the ones with complex fallback logic with try/except. When Amir sees it in review, he will not comment on the lookup style — because there will not be anything to comment on. The code will say exactly what it means.
I am going to run that refactor today. And now I have a name for the pattern — EAFP — so I can explain in the PR why I made the change instead of just saying it looked cleaner.
Python's preference for EAFP over LBYL is not aesthetic — it emerges from how CPython implements dictionary lookups and exception handling at the bytecode level.
Why LBYL costs more than it looks. A Python dictionary stores key-value pairs in a hash table. When you write if key in d, CPython executes LOAD_FAST (load the dict), LOAD_CONST (load the key), and CONTAINS_OP — which calls dict.__contains__, which hashes the key, computes the bucket index, and scans for a matching entry. When you then write d[key], CPython executes the same hash computation, the same bucket index, the same scan. Two full table lookups for one conceptual operation. The EAFP version (d[key] inside try/except) performs the lookup once and installs a handler before entry — a cheap frame-level operation that costs nothing if no exception fires.
The exception fast path. Python exceptions are implemented as C setjmp/longjmp pairs. The try: block calls setjmp to record the current execution context and installs the handler in the frame's exception table. This setup costs a few nanoseconds per try block, regardless of what happens inside. When no exception fires, the except block is never evaluated and the handler is discarded with minimal overhead. The cost of try/except is dominated by the exception case, not the common case. This is why EAFP is cheaper than it looks: the overhead is proportional to the miss rate, not the hit rate.
TOCTOU and concurrent state. Beyond performance, EAFP eliminates a class of correctness bugs. Any check-then-act sequence has a window between the check and the act during which shared state can change. os.path.exists(path) followed by open(path) is the classic example: another process can unlink the file in the nanoseconds between the check and the open. EAFP collapses the window to zero because the check and the act are the same operation. This matters in multi-threaded code, in concurrent async code, and anywhere shared filesystem state is involved.
When to override the preference. EAFP is the right default for operations where failure is rare and the check and the operation are the same cost. It is the wrong choice when failure is frequent (a filtering loop where half the items fail) or when the check is genuinely cheaper than the operation (validating a string format before an expensive parse). The rule is not "always EAFP" — it is "use EAFP when exceptions are genuinely exceptional." The semantic weight of a try/except block signals to readers: "something unusual can happen here." When unusual is actually usual, that signal is misleading and the code is harder to reason about.