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 #

Basic 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))   # inf

Syntax:

  • 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).

LBYL style
if b != 0:
    result = a / b
else:
    result = float("inf")

Python is more idiomatic with EAFP.

EAFP style — Pythonic
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 #

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))   # None

Bundle them as a tuple (Exc1, Exc2).

Different handling? Separate them. #

Different handling
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   # rethrow

as 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.

else / finally
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 when try completed without an exception. For “things to do only on success.”
  • finallyalways, regardless of exceptions. Used for resource cleanup (file close, lock release).

The code above is actually cleaner with with.

with — usually the cleaner way for cleanup
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 #

raise
def withdraw(balance: int, amount: int) -> int:
    if amount <= 0:
        raise ValueError("출금액은 양수여야 합니다")
    if amount > balance:
        raise ValueError(f"잔액 부족: {balance} < {amount}")
    return balance - amount

After 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.

re-raise
try:
    do_work()
except KeyError:
    log("키 없음 발생")
    raise   # bare raise — rethrow the captured exception

A bare raise rethrows the currently captured exception. The traceback is preserved.

raise ... from ... — cause linking #

When you want to wrap one exception in another.

Linking causes with from
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 e

With 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.

Common hierarchy (abridged)
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.

❌ Never do this
try:
    risky()
except:           # bare except — catches KeyboardInterrupt too
    pass

# or
except BaseException:   # same problem
    pass
✅ Catch all regular exceptions
try:
    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.

User-defined exceptions
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 = key

Caller side:

Caller 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.

🚫 Confusing code
def f() -> int:
    try:
        return 1
    finally:
        return 2   # this wins

print(f())   # 2

finally 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*.

Build an ExceptionGroup
errors = ExceptionGroup(
    "여러 작업이 실패",
    [ValueError("a"), TypeError("b"), ValueError("c")],
)
raise errors

except* picks only matching exceptions from the group, rethrowing the rest.

Using except*
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 #

assert
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
Assumption vs input validation
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 #

❌ Too broad
try:
    process(item)
except Exception:
    pass    # ignores everything — hides bugs
✅ Only what you want
try:
    process(item)
except ValueError as e:
    log(f"잘못된 항목: {e}")

2) Keep try blocks short #

❌ Too much in try
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, with is usually cleaner than finally
  • raise and raise ... from ... (cause linking)
  • Catch only up to except Exception — never BaseException or bare except:
  • Define a base class and a hierarchy for user-defined exceptions
  • assert for assumption checks; input validation always uses if + raise
  • ExceptionGroup + 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.

X