DEV Community

Cover image for Python Error Handling: try, except, finally, and raise Done Right
German Yamil
German Yamil

Posted on

Python Error Handling: try, except, finally, and raise Done Right

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Notice the structure:

  • except APIRateLimitError โ€” handle specifically, with the retry_after data
  • 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"
Enter fullscreen mode Exit fullscreen mode

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.


Further Reading

Top comments (0)