Exceptions as Control Flow
Exceptions as Control Flow
Python uses exceptions for failure and for some expected misses. try / except / else / finally is the full shape. The boring default is to catch specific types (ValueError, KeyError) and let the rest surface. Do not write except:. Do not catch Exception unless you are at a process boundary and you will log the type.
Mental model
EAFP — easier to ask forgiveness than permission — means try the operation and handle the error: board[ticket_id] inside except KeyError. LBYL — look before you leap — means test first: if ticket_id not in board.
Both are valid. EAFP is the usual Python style for dicts and files because the check and the use can race in concurrent code, and because the happy path stays unindented. LBYL is clearer when the test is a real business rule (if n <= 0) rather than a type of miss.
try runs the body.
except Type:runs if that error (or a subclass) was raised.elseruns if no exception left thetry.finallyalways runs, whether you returned, raised, or succeeded.
else is for “the conversion worked; now use n.” It is not required. finally is for cleanup when you do not have a with. The next chapters cover with.
Worked examples
Case 1: try / except / else / finally
Save as parse_table.py. Bad text is a ValueError from int. The finally line runs for both paths.
# parse_table.py
def parse_table(raw):
try:
n = int(raw)
except ValueError:
print("not a number")
return
else:
print(f"table {n}")
finally:
print("done reading")
def main():
parse_table("12")
parse_table("twelve")
if __name__ == "__main__":
main()Run:
uv run python parse_table.pyOutput:
table 12
done reading
not a number
done reading
return inside except still runs finally before the function actually returns.
Case 2: EAFP on the board
Save as board_lookup.py. Index the dict. On KeyError, raise a domain error the caller can show.
# board_lookup.py
def table_for(ticket_id, board):
try:
return board[ticket_id]
except KeyError:
raise ValueError(f"ticket {ticket_id} not on the board")
def main():
board = {7: 12, 8: 4}
print(table_for(7, board))
try:
print(table_for(9, board))
except ValueError as e:
print(e)
if __name__ == "__main__":
main()Run:
uv run python board_lookup.pyOutput:
12
ticket 9 not on the board
Catching KeyError and raising ValueError turns a builtin miss into a desk sentence. The next chapter covers raise ... from so the KeyError stays in the traceback on purpose.
Case 3: LBYL when the check is the rule
Save as lbyl_board.py. Membership is the whole story. An if reads better than try here.
# lbyl_board.py
def table_for(ticket_id, board):
if ticket_id not in board:
raise ValueError(f"ticket {ticket_id} not on the board")
return board[ticket_id]
def main():
board = {7: 12}
print(table_for(7, board))
if __name__ == "__main__":
main()Run:
uv run python lbyl_board.pyOutput:
12
Use LBYL for ranges, empty strings, and “is this status paid.” Use EAFP for “the dict may not have this key” and “the file may not exist.”
Case 4: two types in one handler
Save as parse_seats.py. int(None) is TypeError. int("four") is ValueError. Both are “this raw value is not a number.”
# parse_seats.py
def parse_seats(raw):
try:
n = int(raw)
except (TypeError, ValueError) as e:
print(f"skip: {e}")
return None
if n <= 0:
raise ValueError(f"seats {n} must be positive")
return n
def main():
print(parse_seats("4"))
print(parse_seats("four"))
print(parse_seats(None))
if __name__ == "__main__":
main()Run:
uv run python parse_seats.pyOutput:
4
skip: invalid literal for int() with base 10: 'four'
None
skip: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'
None
Bind the exception with as e when you print or wrap it. Parentheses around the types are required for a tuple of exceptions.
The trap
A bare except: catches everything, including KeyboardInterrupt and SystemExit. The process will not die when you press Ctrl-C the way you expect. It will also hide bugs.
# swallow.py
def main():
raw = "twelve"
try:
print(int(raw))
except:
print("something went wrong")
if __name__ == "__main__":
main()Run:
uv run python swallow.pyOutput:
something went wrong
You lost the type and the message. The fix is a specific type, and the message.
# specific_except.py
def main():
raw = "twelve"
try:
print(int(raw))
except ValueError as e:
print(f"not a table number: {e}")
if __name__ == "__main__":
main()Run:
uv run python specific_except.pyOutput:
not a table number: invalid literal for int() with base 10: 'twelve'
except Exception: is still broad. It is acceptable at the top of a CLI if you log repr(e) and exit. It is not acceptable around int(raw) in a helper.
The boring rule
- Catch the types you can handle. Name them.
- Never
except:. Almost neverexcept Exceptioninside a helper. - EAFP for dict keys and I/O. LBYL for rules you can state in English without trying the operation.
- Use
elsefor “thetrysucceeded.” Usefinallyfor cleanup you cannot give towith. - Re-raise a domain exception when the builtin one is the wrong sentence for the caller.
- Bind
as ewhen the message matters. Do not bind and ignore.
Try this
- In
parse_table.py, parse"0"and, in theelse, reject non-positive tables withValueError. - In
board_lookup.py, catchValueErrorinmainfor two missing ids and keep going (print each error). - Change
swallow.pytoexcept ValueError as eand printtype(e).__name__. - Add a path in
parse_seats.pythat callsparse_seats("-1")insidetryand prints theValueErrorfor non-positive seats.