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)
- 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) - Interview patterns — multiple approaches
- 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 / 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#
- 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#
- What is the difference between
elseandfinallyin try/except? - When should you raise vs return
None? - What does EAFP mean and when is LBYL clearer?
- Why shouldn't you use bare
except:? - What happens to
finallywhentryreturns 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 |