Modern Python Basics #6: Errors and exception handling
In #5 Functions — argument patterns we got a firm grasp of function expressiveness. This post is about how to handle those functions when they fail — exception handling.
Python is a language that expresses errors as exceptions. Unlike languages that return error codes, Python defaults to “abnormal cases throw, and the caller catches.” This style is often called EAFP (Easier to Ask for Forgiveness than Permission).
The simplest try / except #
def divide(a: int, b: int) -> float:
try:
return a / b
except ZeroDivisionError:
return float("inf")
print(divide(10, 2)) # 5.0
print(divide(10, 0)) # infSyntax:
- Run code inside
try:that may raise - Catch with
except <ExceptionType>: - Uncaught exceptions propagate up (to the caller)
LBYL vs EAFP #
Other languages usually go “look before you leap” (LBYL).
if b != 0:
result = a / b
else:
result = float("inf")Python is more idiomatic with EAFP.
try:
result = a / b
except ZeroDivisionError:
result = float("inf")Why: Python is dynamically typed, so it’s often hard to check every condition in advance, and the overhead of exception handling is low (when exceptions aren’t the common path).
Catching multiple exceptions #
def parse_int(value: str) -> int | None:
try:
return int(value)
except (ValueError, TypeError):
return None
print(parse_int("42")) # 42
print(parse_int("abc")) # None
print(parse_int(None)) # NoneBundle them as a tuple (Exc1, Exc2).
Different handling? Separate them. #
try:
do_work()
except ValueError as e:
print(f"잘못된 값: {e}")
except KeyError as e:
print(f"키 없음: {e}")
except Exception as e:
print(f"그 외: {e}")
raise # rethrowas e captures the exception object. You can pull info like the message (str(e)) and cause (e.__cause__).
else and finally
#
try has two extra clauses.
def read_file(path: str) -> str:
f = None
try:
f = open(path)
data = f.read()
except FileNotFoundError:
return ""
else:
# runs only when try ended without exception
print(f"읽기 성공: {len(data)} bytes")
return data
finally:
# always runs, regardless of exceptions
if f is not None:
f.close()else— only whentrycompleted without an exception. For “things to do only on success.”finally— always, regardless of exceptions. Used for resource cleanup (file close, lock release).
The code above is actually cleaner with with.
def read_file(path: str) -> str:
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
return ""with takes the close responsibility from finally. We cover this in detail with context managers in the next series (Python Intermediate).
raise — throwing yourself
#
def withdraw(balance: int, amount: int) -> int:
if amount <= 0:
raise ValueError("출금액은 양수여야 합니다")
if amount > balance:
raise ValueError(f"잔액 부족: {balance} < {amount}")
return balance - amountAfter raise, write an exception instance (or class). raise ValueError (class only) works too, but it’s idiomatic to include a message with raise ValueError("...").
Re-throwing #
When you handled the exception in except but want to inform the caller.
try:
do_work()
except KeyError:
log("키 없음 발생")
raise # bare raise — rethrow the captured exceptionA bare raise rethrows the currently captured exception. The traceback is preserved.
raise ... from ... — cause linking
#
When you want to wrap one exception in another.
def load_config(path: str) -> dict:
try:
with open(path) as f:
return parse(f.read())
except (OSError, ValueError) as e:
raise ConfigError(f"설정 로드 실패: {path}") from eWith from e, the traceback shows “the direct cause of this exception is e”. Really useful when debugging.
Exception hierarchy #
Python exceptions form a class hierarchy. Catching a parent catches every child.
BaseException
├─ SystemExit ← sys.exit()
├─ KeyboardInterrupt ← Ctrl+C
└─ Exception ← usually catch only below this
├─ ValueError
│ └─ UnicodeError
├─ TypeError
├─ LookupError
│ ├─ KeyError
│ └─ IndexError
├─ ArithmeticError
│ └─ ZeroDivisionError
├─ OSError
│ └─ FileNotFoundError
└─ ...An important rule: in regular code, catch only up to except Exception. except BaseException or a bare except: swallows things like KeyboardInterrupt, breaking Ctrl+C.
try:
risky()
except: # bare except — catches KeyboardInterrupt too
pass
# or
except BaseException: # same problem
passtry:
risky()
except Exception as e:
log(e)User-defined exceptions #
When building a library/module, defining your own exception classes is good practice. Callers can specify exactly what they want to catch.
class AppError(Exception):
"""이 앱의 모든 예외의 베이스."""
class ConfigError(AppError):
"""설정 관련 오류."""
class DatabaseError(AppError):
"""DB 관련 오류."""
class RowNotFoundError(DatabaseError):
"""필요한 행이 없음."""
def __init__(self, table: str, key: str):
super().__init__(f"{table}에서 {key} 못 찾음")
self.table = table
self.key = keyCaller side:
try:
user = repo.get_user("u1")
except RowNotFoundError as e:
return Response(status=404)
except DatabaseError as e:
log_critical(e)
return Response(status=500)Catch just enough at each point.
finally pitfall — return precedence
#
Returning from finally overrides try’s return. Almost always an antipattern.
def f() -> int:
try:
return 1
finally:
return 2 # this wins
print(f()) # 2finally should only do side-effect cleanup — preferably no return or raise.
Exception Groups — except* (3.11+)
#
For places where multiple jobs may fail simultaneously (asyncio’s TaskGroup, parallel work), a new mechanism for bundling many exceptions and throwing them together was added. That’s ExceptionGroup, and the syntax for catching it is except*.
errors = ExceptionGroup(
"여러 작업이 실패",
[ValueError("a"), TypeError("b"), ValueError("c")],
)
raise errorsexcept* picks only matching exceptions from the group, rethrowing the rest.
try:
raise ExceptionGroup(
"여러 실패",
[ValueError("a"), TypeError("b"), ValueError("c")],
)
except* ValueError as eg:
print("값 오류들:", [str(e) for e in eg.exceptions])
except* TypeError as eg:
print("타입 오류들:", [str(e) for e in eg.exceptions])When will you encounter this? When asyncio.TaskGroup runs many tasks concurrently and two or more fail, an ExceptionGroup is created automatically. Async is covered in a later series; for now, just remember that there are situations where multiple exceptions can arrive simultaneously.
assert — checking assumptions during development
#
def calc_average(items: list[int]) -> float:
assert len(items) > 0, "빈 리스트는 평균을 못 구함"
return sum(items) / len(items)assert condition, message — if the condition is False, AssertionError is raised.
Caution: in python -O (optimization) mode, every assert is stripped. So:
- ✅ Development-time assumption checks (“can never be None at this point”)
- ❌ User input validation — always use a regular
if+raise
def serve(user: User | None):
# ✅ system assumption: auth middleware should have blocked None
assert user is not None, "auth middleware 누락?"
# ❌ Don't block user input with assert
# assert age >= 0, "음수 나이 불가" ← gone with -O
if age < 0:
raise ValueError("나이는 0 이상")Good sense for exception handling #
Finally, some common patterns from code review.
1) Don’t catch too broadly #
try:
process(item)
except Exception:
pass # ignores everything — hides bugstry:
process(item)
except ValueError as e:
log(f"잘못된 항목: {e}")2) Keep try blocks short #
try:
a = compute()
b = transform(a)
c = validate(b)
d = save(c)
return d
except SomeError:
...You don’t know which line raised SomeError. Wrap only the suspect line.
3) Treat any except that just passes with suspicion
#
A bare except: pass is almost always the start of a bug. If the error genuinely should be ignored, add a comment explaining why, or at least log it.
4) Don’t use exceptions for normal flow #
Exceptions are for abnormal cases. Routing ordinary branches through try/except traps the reader. (That said, EAFP is idiomatic in Python, so some uses are perfectly normal — like catching conversion failures from int("abc").)
Wrap-up #
What this post covered:
- EAFP — many places in Python prefer try-and-catch
- The roles of
try/except/else/finally - For resource cleanup,
withis usually cleaner thanfinally raiseandraise ... from ...(cause linking)- Catch only up to
except Exception— neverBaseExceptionor bareexcept: - Define a base class and a hierarchy for user-defined exceptions
assertfor assumption checks; input validation always usesif + raiseExceptionGroup+except*(3.11+) — concurrent exception handling (asyncio TaskGroup, etc.)
In the next post (#7 Modules/packages and pyproject.toml) we organize how to split code across files — the import system, the difference between modules and packages, __init__.py, __main__, and pyproject.toml, all in one place.