Python Error Handling: try, except, finally, and raise Done Right
You wrote a script that calls an API, parses the response, and saves a file. It runs fine for a week. Then one day the API is down โ and your script crashes with a cryptic traceback, or worse, it swallows the error entirely and writes an empty file like nothing happened.
That's what bad exception handling looks like. This article fixes it.
๐ Free: AI Publishing Checklist โ 7 steps in Python ยท Full pipeline: germy5.gumroad.com/l/xhxkzz (pay what you want, min $9.99)
The Problem: Bare except Catches Everything
The most dangerous pattern in beginner Python code:
# BAD โ do not do this
try:
data = fetch_data(url)
save(data)
except:
pass
What does except: pass actually catch? Everything. Including:
-
KeyboardInterruptโ your Ctrl+C now does nothing -
SystemExitโsys.exit()is silently swallowed -
MemoryErrorโ you ran out of RAM, script keeps running - Your actual bug, hidden forever
The script "succeeds" and writes nothing, and you have no idea why.
try/except Basics: Catch the Right Exception
Name the exception you expect. Every stdlib function documents what it raises โ use that.
# BAD
try:
with open("config.json") as f:
config = json.load(f)
except:
config = {}
# GOOD
try:
with open("config.json") as f:
config = json.load(f)
except FileNotFoundError:
config = {} # file doesn't exist yet โ that's fine, use defaults
except json.JSONDecodeError as e:
raise RuntimeError(f"config.json is malformed: {e}") from e
The as e binding gives you the exception object. Use it โ log it, include it in a message, or re-raise it. Never throw away the information.
Multiple except Clauses and Exception Hierarchy
You can stack multiple except blocks. Python checks them top to bottom and runs the first match.
import urllib.request
import json
def fetch_json(url: str) -> dict:
try:
with urllib.request.urlopen(url, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
print(f"HTTP {e.code}: {e.reason}")
return {}
except urllib.error.URLError as e:
print(f"Network error: {e.reason}")
return {}
except json.JSONDecodeError as e:
print(f"Bad JSON at char {e.pos}: {e.msg}")
return {}
Order matters because of inheritance. URLError is the parent of HTTPError โ if you put URLError first, HTTPError will never be reached. Most specific exceptions go first.
You can also group unrelated exceptions that share the same handler:
except (TimeoutError, ConnectionResetError) as e:
print(f"Connection problem: {e}")
The else Clause: Rarely Taught, Very Useful
else runs when the try block completes with no exception. This lets you separate "the thing that might fail" from "what to do when it succeeds" โ without wrapping the success code inside try where it might accidentally catch the wrong error.
# Without else โ risky: process() errors are caught by except
try:
data = fetch_json(url)
except urllib.error.URLError:
data = {}
else:
# only runs if fetch_json() succeeded
result = process(data) # errors here propagate normally
save(result)
Use else whenever the code after a successful operation could itself raise โ you don't want those errors mixed up with the errors from the operation you were protecting.
finally: Guaranteed Cleanup, Even on Crash
finally always runs. Exception or no exception, return statement or not.
def write_report(path: str, content: str) -> None:
f = open(path, "w")
try:
f.write(content)
except OSError as e:
print(f"Write failed: {e}")
raise
finally:
f.close() # always runs โ file is never left open
This is exactly what with open(...) does for you under the hood. Use finally for resources that don't have a context manager: database cursors, raw sockets, temp directories you manage manually, locks.
import threading
lock = threading.Lock()
def update_shared_state(value):
lock.acquire()
try:
shared_state["count"] += value
finally:
lock.release() # always released, even if += raises
raise: Re-Raising and Raising New Exceptions
Three forms you need to know:
# 1. Raise a new exception
raise ValueError("price must be positive")
# 2. Re-raise the current exception (inside an except block)
try:
connect()
except ConnectionError:
log.error("Connection failed")
raise # propagates the original exception unchanged
# 3. Raise a new exception while preserving the original cause
try:
raw = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid pipeline config") from e
Form 3 is exception chaining. The from e attaches the original error as __cause__, so the full traceback shows both errors. Python will print "The above exception was the direct cause of the following exception" โ invaluable for debugging.
Custom Exception Classes
When your library or pipeline has its own failure modes, define your own exceptions. Callers can then catch yours specifically without catching everything.
# Define once in exceptions.py
class PipelineError(Exception):
"""Base class for all pipeline errors."""
class APIRateLimitError(PipelineError):
"""Raised when the LLM API returns 429."""
def __init__(self, retry_after: int = 60):
self.retry_after = retry_after
super().__init__(f"Rate limited โ retry after {retry_after}s")
class ChapterValidationError(PipelineError):
"""Raised when generated content fails quality checks."""
def __init__(self, chapter_id: str, reason: str):
self.chapter_id = chapter_id
super().__init__(f"Chapter {chapter_id} failed validation: {reason}")
Now callers can be precise:
try:
content = generate_chapter("ch03")
except APIRateLimitError as e:
time.sleep(e.retry_after)
content = generate_chapter("ch03") # retry after waiting
except ChapterValidationError as e:
log.warning(f"Skipping {e.chapter_id}, will regenerate later")
except PipelineError as e:
log.error(f"Unhandled pipeline error: {e}")
raise
The hierarchy APIRateLimitError โ PipelineError โ Exception means you can catch broadly (all pipeline errors) or narrowly (only rate limits), depending on where you are in the code.
Exception Chaining in Practice
Here is the pattern the pipeline uses when calling external services:
import anthropic
def call_llm(prompt: str, model: str = "claude-sonnet-4-6") -> str:
client = anthropic.Anthropic()
try:
message = client.messages.create(
model=model,
max_tokens=4096,
messages=[{"role": "user", "content": prompt}],
)
return message.content[0].text
except anthropic.RateLimitError as e:
raise APIRateLimitError(retry_after=60) from e
except anthropic.APIConnectionError as e:
raise PipelineError("LLM unreachable โ check network") from e
except anthropic.APIStatusError as e:
raise PipelineError(f"LLM API error {e.status_code}: {e.message}") from e
The original SDK exception is preserved as __cause__. When this crashes in production, you see both the pipeline-level message and the raw SDK error in the traceback.
Real Pipeline Pattern: LLM + File + Subprocess Errors
Putting it all together โ a realistic generation loop with proper error handling at every layer:
import time
import logging
from pathlib import Path
log = logging.getLogger(__name__)
def generate_and_save(chapter_id: str, prompt: str, output_dir: Path) -> bool:
"""
Generate one chapter and save it. Returns True on success.
Raises PipelineError on unrecoverable failure.
"""
max_retries = 3
delay = 5.0
for attempt in range(1, max_retries + 1):
try:
log.info(f"[{chapter_id}] Attempt {attempt}/{max_retries}")
content = call_llm(prompt)
except APIRateLimitError as e:
log.warning(f"[{chapter_id}] Rate limited, waiting {e.retry_after}s")
time.sleep(e.retry_after)
continue
except PipelineError:
if attempt == max_retries:
raise
log.warning(f"[{chapter_id}] LLM call failed, retrying in {delay}s")
time.sleep(delay)
delay *= 2
continue
else:
# LLM call succeeded โ now handle file errors separately
try:
out_path = output_dir / f"{chapter_id}.md"
out_path.write_text(content, encoding="utf-8")
log.info(f"[{chapter_id}] Saved to {out_path}")
return True
except OSError as e:
raise PipelineError(f"Cannot write {chapter_id}") from e
return False # exhausted retries without raising
Notice the structure:
-
except APIRateLimitErrorโ handle specifically, with theretry_afterdata -
except PipelineErrorโ handle the general case, re-raise on final attempt -
elseโ file write is outside the LLM retry logic entirely - File errors raise immediately (no retry), chained to the original
OSError
What NOT to Do: Swallowing Exceptions Silently
A quick checklist of patterns that destroy debugging:
# NEVER โ hides all errors
except:
pass
# NEVER โ hides the error message, logs nothing useful
except Exception:
pass
# BAD โ logs something but still swallows it;
# the caller thinks the function succeeded
except Exception as e:
print(f"Error: {e}")
return None # caller gets None, has no idea why
# BAD โ catching broad Exception when you mean a specific error
try:
value = d["key"]
except Exception: # should be KeyError
value = "default"
# GOOD โ specific, logged, re-raised or handled with intent
except KeyError:
log.warning("Key missing from config, using default")
value = "default"
If you catch an exception and don't re-raise it, you are making a deliberate decision that the error is recoverable and the caller doesn't need to know. Make that decision consciously, not by habit.
The full pipeline wraps every LLM call and file write in try/except/finally โ here's how the retry logic works end-to-end: germy5.gumroad.com/l/xhxkzz โ pay what you want, min $9.99.
Top comments (0)