Asking forgiveness, and failing fast
Python has a different instinct about errors from most languages, and it has a name: easier to ask forgiveness than permission. Understanding when that is right — and when the other instinct is right — is most of writing Python that does not surprise people.
EAFP and LBYL
# LBYL — look before you leap
if "category" in entry and entry["category"] is not None:
category = entry["category"]
else:
category = "uncategorised"
# EAFP — easier to ask forgiveness than permission
try:
category = entry["category"]
except KeyError:
category = "uncategorised"
Python leans EAFP, for two reasons that are worth knowing rather than just accepting.
The check can be wrong by the time you act. Between if os.path.exists(path)
and open(path), the file can be deleted. The try cannot have that gap —
there is no window between checking and doing, because there is no check.
It is often faster. The happy path costs nothing; a try block that does not
raise is essentially free in CPython, whereas an if runs every time.
But EAFP is not always right:
# EAFP here is worse
try:
amount = int(text)
except ValueError:
amount = 0
That swallows a genuine problem. If text is "abc" the user made a mistake and
should be told, not silently given zero. EAFP is for handling an expected
absence, not for hiding a real error.
And for a dictionary, Python gives you something better than either:
category = entry.get("category") or "uncategorised"
category = entry.get("category", "uncategorised")
Catch the narrowest thing
# never
try:
save(entries)
except:
print("something went wrong")
A bare except catches KeyboardInterrupt and SystemExit too, so Ctrl+C
stops working and your program cannot be shut down. Ruff flags it (E722) and
it is the single worst habit in Python error handling.
# barely better
except Exception:
print("something went wrong")
Catches everything you did not think about — a typo producing AttributeError,
a bug producing TypeError — and reports them as if they were expected.
# right
except (ValueError, KeyError) as e:
raise ValueError(f"could not read row {number}: {e}") from e
Name what you expect. Anything else should crash, loudly, with a traceback pointing at the real line — which is far more useful than a sentence saying something went wrong.
raise ... from e
raise ValueError(f"bad date in row {number}") from e
from e chains the exceptions, so the traceback shows both your message and
the original cause:
ValueError: could not parse '2026-13-01'
The above exception was the direct cause of the following exception:
ValueError: bad date in row 3
Without from e you get "During handling of the above exception, another
exception occurred", which reads as an accident rather than a deliberate
translation. Use from e when you are deliberately re-raising as a different
type, and from None on the rare occasion the original genuinely adds nothing.
Fail fast, at the boundary
The worst thing a program can do with bad input is accept it and carry on. A bad value stored, passed through four functions and used at the end gives you an error in code that did nothing wrong.
Check at the edge — where data enters — and not again:
@dataclass(frozen=True)
class Expense:
date: date
description: str
amount_paise: int
def __post_init__(self):
if not self.description.strip():
raise ValueError("description must not be blank")
if self.amount_paise <= 0:
raise ValueError(f"amount must be positive, got {self.amount_paise}")
frozen=True makes it immutable, and __post_init__ runs after the fields are
set — so an invalid Expense cannot exist past construction. Every function
receiving one is then free of defensive checks, because there is no path by
which a bad one arrives.
Validate once, at construction, and let the type carry the guarantee.
Which exception
| Situation | Raise |
|---|---|
| A value is the wrong type | TypeError |
| A value is the right type but unacceptable | ValueError |
| A key is missing | KeyError |
| The object is in the wrong state | RuntimeError |
| A rule of your domain was broken | your own exception |
A domain rule deserves a domain exception, and it should carry the data, not just the message:
class BudgetExceeded(Exception):
def __init__(self, category: str, limit_paise: int, spent_paise: int):
super().__init__(
f"{category} budget of {limit_paise} exceeded by {spent_paise - limit_paise}"
)
self.category = category
self.limit_paise = limit_paise
self.spent_paise = spent_paise
The caller can then tell the user by how much, which it cannot do if you raised
Exception("budget exceeded").
Never return None for a collection
# bad
def expenses_for(category):
if category not in self._index:
return None
# good
def expenses_for(category):
return self._index.get(category, [])
An empty list works in a for, in a comprehension, in len(). None raises.
Returning None forces every caller to check, and the one who forgets finds out
in production.
finally and with
f = open(path)
try:
process(f)
finally:
f.close() # runs whether or not process raised
Which is exactly what with does, and why you should use it:
with open(path, encoding="utf-8") as f:
process(f)
Always pass encoding=. Without it Python uses the platform default, so a file
written on your Mac reads differently on a Windows server — a bug that appears
only in production and only for rows with a rupee sign or an accented name.
Do not over-defend
The counterweight, because this advice has a failure mode too:
def total_paise(expenses):
if expenses is None:
return 0 # no
if not isinstance(expenses, list):
return 0 # no
return sum(e.amount_paise for e in expenses if e is not None) # no
Three checks that cannot fire if your boundaries are right, each silently returning a wrong answer instead of failing. Returning 0 for invalid input is worse than raising, because the total is now quietly wrong and nobody will know until somebody reconciles it.
Check at the boundary. Inside, trust your own types. If you feel the need to re-check something a constructor guaranteed, either the constructor is not guaranteeing it, or the check is superstition.
Check your work
What EAFP is and why Python prefers it: no gap between checking and acting, and the happy path costs nothing.
When EAFP is wrong: when it swallows a genuine error rather than handling an expected absence.
Why bare except is the worst habit: it catches KeyboardInterrupt, so
Ctrl+C stops working.
Why except Exception is barely better: it reports your own bugs as if they
were expected.
What from e does: chains the cause, so the traceback shows a deliberate
translation rather than an accident.
What frozen=True plus __post_init__ buys: an invalid instance cannot
exist past construction.
Why a domain exception carries data: the caller can act on it.
Why never return None for a collection: an empty list works everywhere.
Why always pass encoding=: the platform default differs, so the bug
appears only in production.
Why over-defending is its own bug: a default for invalid input makes the answer quietly wrong instead of loudly absent.
Practice
- Write both the LBYL and EAFP versions of a dictionary lookup, then replace
both with
.get(). - Write a bare
exceptaround a long loop and try to stop the program with Ctrl+C. - Narrow it to the specific exception and confirm Ctrl+C works again.
- Raise a
ValueErrorfrom inside anexceptblock, once withfrom eand once without. Compare the tracebacks. - Write the
Expensedataclass above and try to construct one with a blank description and with a negative amount. - Remove
frozen=Trueand mutate an instance after construction. Say what guarantee you lost. - Write
BudgetExceededcarrying the numbers, raise it, catch it, and print a message a user could act on. - Find a function of yours returning
Nonefor a collection and change it. - Open a file containing a
₹withoutencoding=, then withencoding="utf-8". - Find a check in your code that can never fire. Remove it and explain why that was safe.
Next: reading other people's code, and reviewing it.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship