Last 30 Days
No notifications
Errors are inevitable in programming. Python's exception handling system lets you catch, handle, and recover from errors gracefully using try, except, else, and finally.
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero!")try:
value = int(input("Enter a number: "))
except ValueError:
print("Invalid input!")
else:
print(f"You entered: {value}") # Runs only if no exception
finally:
print("Cleanup here.") # Always runs| Exception | Cause |
ValueError | Wrong value type (e.g., int("abc")) |
TypeError | Wrong operation for type (e.g., "a" + 1) |
KeyError | Missing dictionary key |
IndexError | List index out of range |
FileNotFoundError | File doesn't exist |
ZeroDivisionError | Division by zero |
AttributeError | Object has no such attribute |
ImportError | Module import fails |
try:
data = [1, 2, 3]
print(data[10])
except (IndexError, KeyError) as e:
print(f"Lookup error: {e}")
except Exception as e:
print(f"Unexpected: {e}")def set_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
return ageclass InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f"Cannot withdraw ${amount}: only ${balance} available"
)assert len(data) > 0, "Data must not be empty"> Tip: Catch specific exceptions — never use bare except:. The else block is useful for code that should only run when no error occurred, and finally is perfect for cleanup like closing connections.
Errors aren't bugs to hide — they're *information*. Python's exception system lets you signal that something went wrong, decide where to handle it, and clean up resources cleanly. Get this right and your programs become honest, debuggable, and resilient.
When something goes wrong, Python raises an exception — a special object that walks back up the call stack until something catches it (or the program crashes with a traceback). Examples:
int("abc") # ValueError: invalid literal for int()
[1, 2][5] # IndexError
{"a": 1}["b"] # KeyError
open("missing.txt") # FileNotFoundError
1 / 0 # ZeroDivisionErrortry:
x = int(user_input)
except ValueError:
print("not a number")
except (TypeError, KeyError) as e:
print("some other error:", e)
else:
print("parsed:", x) # only if no exception
finally:
print("always runs") # cleanuptry — the risky code.except — catches specific types (catch the narrowest type you expect).else — runs if no exception fired (keeps the success path tiny).finally — always runs, even on return or unhandled re-raises.def set_age(age):
if not isinstance(age, int):
raise TypeError("age must be int")
if age < 0:
raise ValueError("age must be non-negative")Validate at function boundaries; raise *specific* exception types so callers can react. Don't raise Exception(...) — too broad.
1. Bare except:. Catches *everything* — including KeyboardInterrupt and SystemExit. At minimum use except Exception:; better, name the type.
2. Silently swallowing errors (except: pass). Hides bugs forever. At least log the exception.
3. Catching too wide. except Exception: around 50 lines means you can't tell what failed. Wrap only the line that may raise.
4. Using exceptions for control flow in hot loops. They're slower than checks. (Some EAFP is fine — see below — just don't loop on it.)
5. raise e losing the traceback. Just raise (no argument) re-raises with full context.
6. Using assert for input validation. Asserts can be disabled with python -O. Use real raise for production checks.
Python culture prefers EAFP — "Easier to Ask Forgiveness than Permission":
# EAFP — try and handle
try:
value = config["port"]
except KeyError:
value = 8080# LBYL — look before you leap
if "port" in config:
value = config["port"]
else:
value = 8080
EAFP is often clearer and avoids race conditions (especially for files). For dicts, config.get("port", 8080) is cleanest of all.
class AppError(Exception):
"""Base for all app-level errors."""class UserNotFound(AppError):
def __init__(self, user_id):
super().__init__(f"user {user_id} not found")
self.user_id = user_id
try:
fetch_user(42)
except UserNotFound as e:
log.warn("missing", id=e.user_id)
Creating your own exception hierarchy lets callers catch your library's errors without catching unrelated ones.
withWhenever you'd reach for try / finally to release a resource, prefer with:
with open("file") as f:
data = f.read() # auto-close, even on exceptionsimport threading
lock = threading.Lock()
with lock:
critical_section() # auto-release
For anything that has "acquire / release" semantics, there's almost always a context manager.
Traceback (most recent call last):
File "main.py", line 10, in <module>
process(items)
File "main.py", line 4, in process
return totals[i] / count
ZeroDivisionError: division by zeroRead bottom up:
1. Last line = the actual exception class + message. 2. Line above = the file/line where it was raised. 3. Earlier frames = how you got there.
90% of debugging is calmly reading this.
try:
value = int(text)
except ValueError as e:
raise RuntimeError("could not parse config") from efrom e preserves the original cause in the traceback (The above exception was the direct cause of...). Use it when re-raising as a higher-level error — keeps the diagnostic trail intact.
Use raise NewError(...) from None to suppress the chain when the underlying cause is implementation noise.
try:
run_parallel_tasks()
except* TimeoutError as eg:
log.warn("timeouts:", eg.exceptions)
except* ValueError as eg:
log.error("bad inputs:", eg.exceptions)ExceptionGroup and except* let you handle *multiple* simultaneous failures (e.g., from asyncio.TaskGroup). Modern concurrency depends on this.
logging vs print for Errorsimport logging
log = logging.getLogger(__name__)try:
risky()
except SomeError:
log.exception("risky failed") # logs full traceback at ERROR level
log.exception includes the full traceback automatically. In production, configure logging once and forget about print.
A solid rule: handle exceptions at system boundaries (HTTP request handlers, CLI entry points, job runners). Inner code should *raise*, not *swallow*. This keeps business logic clean and gives you exactly one place to log + return a friendly response.
@app.post("/users")
def create_user(payload):
try:
user = users.create(payload)
return user.to_json()
except UserAlreadyExists:
return {"error": "duplicate"}, 409
except ValidationError as e:
return {"error": str(e)}, 400
except Exception:
log.exception("unexpected")
return {"error": "server error"}, 5001. Wrap int(input("n: ")) in a try/except ValueError loop that re-prompts until valid.
2. Define a custom InsufficientFunds exception and raise it from a withdraw method; catch it at the call site.
3. Use raise ... from e when wrapping a low-level KeyError as a higher-level ConfigMissing.
4. Replace a try/finally that closes a file with a with open(...) block; verify cleanup still happens on a thrown exception.