RizTech Academy logo
RizTech Academy
Best Practices: Code Others Can ReadLesson 4 of 535 min

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

  1. Write both the LBYL and EAFP versions of a dictionary lookup, then replace both with .get().
  2. Write a bare except around a long loop and try to stop the program with Ctrl+C.
  3. Narrow it to the specific exception and confirm Ctrl+C works again.
  4. Raise a ValueError from inside an except block, once with from e and once without. Compare the tracebacks.
  5. Write the Expense dataclass above and try to construct one with a blank description and with a negative amount.
  6. Remove frozen=True and mutate an instance after construction. Say what guarantee you lost.
  7. Write BudgetExceeded carrying the numbers, raise it, catch it, and print a message a user could act on.
  8. Find a function of yours returning None for a collection and change it.
  9. Open a file containing a ₹ without encoding=, then with encoding="utf-8".
  10. 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