Skip to content

Basic Error Handling#

Errors are normal in interviews — how you handle invalid input, missing keys, and edge cases signals production-ready thinking. Python uses exceptions for failure paths rather than C-style error codes. This page covers the full exception model, multiple handling strategies, and when to raise vs return.

How to use this page

Learn try/except/else/finally, exception hierarchy, and EAFP vs LBYL. Deeper patterns: Error Handling.

At a glance
Track Python Basics
Sections 10 major topics
Outline Use the right-hand TOC to jump

Topics: Exceptions vs return codes · Exception hierarchy · try / except / else / finally · Raising exceptions · EAFP vs LBYL — two philosophies · Avoiding lookup and bounds errors · Assertions · Context managers (with) · … (+2 more)

  1. Exceptions vs return codes
  2. Exception hierarchy
  3. try / except / else / finally
  4. Raising exceptions
  5. EAFP vs LBYL — two philosophies
  6. Avoiding lookup and bounds errors
  7. Assertions
  8. Context managers (with)
  9. Interview patterns — multiple approaches
  10. What not to do

Exceptions vs return codes#

Approach Python style When
Return sentinel (-1, None) Common in DSA Expected "not found" in algorithm problems
Return Result tuple (ok, value) Explicit in APIs Parsing, validation pipelines
Raise exception Idiomatic Python Invalid caller input, impossible state, I/O failure
# Algorithm style — not found is normal
def find_index(nums: list[int], target: int) -> int:
    for i, x in enumerate(nums):
        if x == target:
            return i
    return -1

# API style — bad input is exceptional
def divide(a: float, b: float) -> float:
    if b == 0:
        raise ValueError("divisor must be non-zero")
    return a / b

Exception hierarchy#

BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
    ├── StopIteration
    ├── ArithmeticError
    │   ├── ZeroDivisionError
    │   └── OverflowError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── ValueError
    ├── TypeError
    ├── AttributeError
    ├── OSError
    │   ├── FileNotFoundError
    │   └── PermissionError
    └── ... (many more)

Catch the most specific type you can handle. Broad catches hide bugs.

Common exceptions in interviews#

Exception When it fires Typical fix
ValueError Valid type, bad value Validate input; int("abc")
TypeError Wrong type for operation Check types; "a" + 1
KeyError Missing dict key d[k] .get(), in check
IndexError Index out of range Bounds check
AttributeError Missing attr/method hasattr, getattr with default
ZeroDivisionError / or % by zero Guard divisor
RecursionError Stack overflow Iterate or memoize
StopIteration next() on exhausted iterator Usually don't catch — use for
try:
    int("not a number")
except ValueError as e:
    print(type(e), e)

try / except / else / finally#

def read_config(path: str) -> dict:
    f = None
    try:
        f = open(path)
        data = parse(f.read())
    except FileNotFoundError:
        return {}
    except ValueError as e:
        raise RuntimeError(f"invalid config: {path}") from e
    else:
        # runs ONLY if try completed without exception
        log.info("loaded %s", path)
    finally:
        # ALWAYS runs — cleanup
        if f is not None:
            f.close()
    return data

# Better: context manager
def read_config_clean(path: str) -> dict:
    try:
        with open(path) as f:
            return parse(f.read())
    except FileNotFoundError:
        return {}
Clause Runs when
try Body that might fail
except Matching exception raised in try
else try succeeded (no exception)
finally Always — even on return, break, new exception

Execution order with return#

def demo():
    try:
        return 1
    finally:
        print("cleanup")   # runs BEFORE return value is delivered
    return 2               # unreachable if try returns

demo()  # prints cleanup, returns 1

Catching multiple exceptions#

# Tuple — any listed type
try:
    ...
except (ValueError, TypeError) as e:
    handle(e)

# Separate handlers — different logic per type
try:
    ...
except ValueError:
    ...
except TypeError:
    ...

# Base class catches subclasses
except LookupError:   # catches KeyError and IndexError
    ...

Bare except — never in production#

try:
    solve()
except:           # catches EVERYTHING including KeyboardInterrupt
    pass

try:
    solve()
except Exception:  # still catches most errors — use specific types
    log.exception("failed")

Raising exceptions#

def validate_age(age: int) -> None:
    if age < 0:
        raise ValueError(f"age must be non-negative, got {age}")
    if age > 150:
        raise ValueError(f"unrealistic age: {age}")

Custom exception classes#

class InvalidIntervalError(ValueError):
    """Raised when interval start > end."""
    pass

class AppError(Exception):
    """Base for application errors."""
    pass

class NotFoundError(AppError):
    pass

Inherit from Exception (or a domain base), not from BaseException.

Exception chaining#

try:
    data = json.loads(raw)
except json.JSONDecodeError as e:
    raise ValueError("config file is not valid JSON") from e
    # __cause__ links to original

raise ... from None suppresses the cause display when wrapping.

Re-raising#

try:
    risky()
except ValueError:
    log.error("handling partial failure")
    raise   # re-raise same exception with original traceback

EAFP vs LBYL — two philosophies#

EAFP: Easier to Ask Forgiveness than Permission
LBYL: Look Before You Leap

Dict access — four approaches#

cache: dict[str, int] = {}

# 1. EAFP
try:
    value = cache[key]
except KeyError:
    value = compute(key)
    cache[key] = value

# 2. LBYL
if key in cache:
    value = cache[key]
else:
    value = compute(key)
    cache[key] = value

# 3. .get() with sentinel
value = cache.get(key)
if value is None:          # careful: None may be valid value!
    value = compute(key)
    cache[key] = value

# 4. .get() with default (when default is sufficient)
value = cache.get(key, 0)
Situation Prefer
Key usually exists EAFP or direct d[k]
Key often missing .get() or LBYL
None is valid stored value 'key' in d not if d.get(k)

Attribute access#

# EAFP
try:
    result = obj.process()
except AttributeError:
    result = default_process()

# LBYL
if hasattr(obj, "process"):
    result = obj.process()
else:
    result = default_process()

# getattr with default — often clearest
result = getattr(obj, "process", default_process)()

Python culture favors EAFP when the happy path is common and checks add noise.


Avoiding lookup and bounds errors#

Dictionary patterns#

freq: dict[str, int] = {}

freq["a"] = freq.get("a", 0) + 1
freq.setdefault("a", 0)
freq["a"] += 1

from collections import defaultdict
counts = defaultdict(int)
counts["a"] += 1

List bounds#

# Guard
if 0 <= index < len(nums):
    val = nums[index]

# Clamp (use carefully — may hide bugs)
index = max(0, min(index, len(nums) - 1))

Assertions#

assert len(nums) > 0, "nums must be non-empty"
assert invariant, f"broken at step {step}"
  • Disabled with python -O (optimizations)
  • Never validate user input with assert
  • Fine for internal invariants during development and interviews
# BAD for user input
assert age >= 0

# GOOD
if age < 0:
    raise ValueError("age must be non-negative")

Context managers (with)#

Guarantee cleanup even when exceptions occur:

with open("data.txt") as f:
    process(f.read())
# f closed automatically

from contextlib import contextmanager

@contextmanager
def timer():
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"elapsed: {time.perf_counter() - start:.3f}s")

Built-in context managers: files, locks (threading.Lock), tempfile.TemporaryDirectory, decimal.localcontext.


Interview patterns — multiple approaches#

Safe parsing#

# Approach 1: try/except
def safe_int(s: str, default: int = 0) -> int:
    try:
        return int(s)
    except ValueError:
        return default

# Approach 2: str methods (when constrained)
def safe_int_digits(s: str, default: int = 0) -> int:
    if s.lstrip("-").isdigit():
        return int(s)
    return default

# Approach 3: regex
import re
def safe_int_re(s: str, default: int = 0) -> int:
    m = re.match(r"-?\d+", s.strip())
    return int(m.group()) if m else default

Validate early, fail fast#

def binary_search(nums: list[int], target: int) -> int:
    if not nums:
        return -1
    lo, hi = 0, len(nums) - 1
    ...

Optional result vs exception#

from typing import Optional

def find_user(id: int) -> Optional[dict]:
    user = db.get(id)
    return user   # None if missing

def get_user(id: int) -> dict:
    user = db.get(id)
    if user is None:
        raise NotFoundError(f"user {id}")
    return user

Use Optional return for expected absence; raise when absence violates contract.


What not to do#

# Swallow everything
try:
    solve()
except Exception:
    pass

# Catch too broad for known case
try:
    x = d[key]
except Exception:
    x = 0

# Use exception for control flow in hot loop
for key in keys:
    try:
        total += cache[key]
    except KeyError:
        pass   # prefer: total += cache.get(key, 0)

Interview traps (quick reference)#

Trap What goes wrong Safe approach
Bare except: Catches KeyboardInterrupt Specific types
except Exception: pass Silent failure Log and re-raise or handle
Assert for input Stripped with -O raise ValueError
.get() when None is valid Can't distinguish missing vs None key in d
Exception in finally Masks original exception Minimal finally body
else confusion Runs only if no exception in try Not "if no error handler ran"
Over-using exceptions Slow, unclear control flow .get(), guards for expected cases

Mental model checklist#

  1. What is the difference between else and finally in try/except?
  2. When should you raise vs return None?
  3. What does EAFP mean and when is LBYL clearer?
  4. Why shouldn't you use bare except:?
  5. What happens to finally when try returns a value?

What's next#

Topic Page
Functions and contracts Functions and Code Reuse
File I/O errors File Handling
Advanced exception design Error Handling
Testing error paths Unit Testing