Error Handling in Python: try/except/else/finally, raise, and Custom Exceptions

⏱️ 4 min read 🐍 Python

Error handling in Python is built around four keywords: try runs risky code, except catches specific failures, else runs when nothing failed, and finally always runs for cleanup. Done well, it makes failures loud, informative, and recoverable; done badly (bare except: pass), it silently buries bugs.

Quick answer: Handle errors in Python with try/except, catching the most specific exception type possible: try: x = int(s) except ValueError: .... Add else for code that runs only on success and finally for cleanup that always runs. Never use a bare except: — it swallows bugs and even Ctrl+C. Re-raise wrapped errors with raise NewError(...) from original to preserve the cause.

What do try, except, else, and finally each do?

The try block holds the code that may fail. Each except clause handles one kind of failure. else runs only if the try block raised nothing — put success-path code there so its own errors aren't accidentally caught. finally runs no matter what, even on return or an uncaught exception, which makes it the place for cleanup.

try:
    file = open('data.txt', 'r')          # risky code only
except FileNotFoundError:
    print("File not found")               # one failure mode
else:
    content = file.read()                 # runs only on success
    print(f"Read {len(content)} characters")
    file.close()
finally:
    print("Done — runs every time")       # always runs

# Better for files: a context manager closes for you
try:
    with open('data.txt', 'r') as file:
        content = file.read()
except FileNotFoundError:
    print("File not found")

For resource cleanup, prefer with over manual finally blocks — see context managers and the with statement.

Should you ever use a bare except in Python?

Almost never. A bare except: catches everything, including KeyboardInterrupt and SystemExit, so your program can't even be stopped cleanly. except Exception: is barely better — it hides typos like NameError and AttributeError as if they were expected failures. Catch the narrowest type that represents the failure you can actually handle.

# Bad: hides bugs AND blocks Ctrl+C ❌
try:
    process_data()
except:
    pass

# Bad: still too broad ❌
try:
    process_data()
except Exception:
    pass

# Good: specific exceptions, logged, re-raised ✅
try:
    process_data()
except (ValueError, KeyError) as e:
    logger.error(f"Data processing failed: {e}")
    raise

# Multiple exception types in one clause
try:
    data = json.loads(file_content)
except (json.JSONDecodeError, FileNotFoundError) as e:
    print(f"Error loading data: {e}")

How do you raise an exception with context (raise ... from)?

Use a bare raise inside an except block to re-raise the original exception unchanged. Use raise NewError(...) from e when translating a low-level error into a domain-level one — the traceback then shows "The above exception was the direct cause of the following exception," so the root cause is never lost.

# Re-raise unchanged after logging
try:
    data = fetch_data()
except ConnectionError:
    logger.error("Failed to fetch data")
    raise  # original traceback preserved

# Translate with explicit cause
try:
    parse_config()
except json.JSONDecodeError as e:
    raise ConfigError("Invalid config file") from e
# Traceback shows BOTH errors, chained

# Validate inputs by raising early
def process_age(age):
    if not isinstance(age, int):
        raise TypeError("Age must be an integer")
    if age < 0:
        raise ValueError("Age cannot be negative")
    return age

When should you create a custom exception?

Create one when callers need to distinguish your failure from generic ones — "config invalid" vs any old ValueError. Subclass Exception (never BaseException), give it a descriptive name ending in Error, and define one base class per library so callers can catch your whole family with a single clause.

# One base class per project/library
class AppError(Exception):
    """Base for all errors raised by this app."""

class DataValidationError(AppError):
    """Raised when data validation fails."""

class DatabaseConnectionError(AppError):
    def __init__(self, host, message="Cannot connect"):
        self.host = host
        super().__init__(f"{message} to {host}")

# Use them
def validate_email(email):
    if '@' not in email:
        raise DataValidationError(f"Invalid email: {email}")
    return email

# Callers can be as broad or narrow as they need
try:
    run_pipeline()
except AppError as e:      # catches every app-specific error
    notify_oncall(e)

What is EAFP vs LBYL?

EAFP ("Easier to Ask Forgiveness than Permission") means just attempt the operation and catch the exception. LBYL ("Look Before You Leap") means check preconditions first. Python favors EAFP: it avoids race conditions (the file can vanish between your exists() check and your open()) and it's faster when failure is rare.

# LBYL: check first — racy, two lookups
if 'key' in my_dict:
    value = my_dict['key']
else:
    value = 'default'

# EAFP: just try — atomic, Pythonic
try:
    value = my_dict['key']
except KeyError:
    value = 'default'

# (For this exact case, my_dict.get('key', 'default') beats both)

Rule of thumb: use EAFP when failure is exceptional; use LBYL when failure is the common case, because raising an exception costs far more than a boolean check.

How do you log exceptions properly?

Inside an except block, call logger.exception("message") — it logs at ERROR level and automatically appends the full traceback. Using print(e) or logger.error(str(e)) throws away the traceback, which is usually the only thing that tells you where it broke. Log once, at the level that handles the error — not at every level it passes through.

import logging
logger = logging.getLogger(__name__)

try:
    result = risky_operation()
except ValueError:
    logger.exception("risky_operation failed")   # full traceback included
    raise

# Equivalent: logger.error("...", exc_info=True)

Common Exception Types

# ValueError: right type, invalid value
int("not a number")            # ValueError

# KeyError: missing dictionary key
my_dict['missing_key']         # KeyError → prefer .get() with default

# TypeError: wrong type entirely
"text" + 5                     # TypeError

# FileNotFoundError: file doesn't exist
open('missing.txt')            # FileNotFoundError

# IndexError: list index out of range
my_list[10]                    # IndexError

Many of these show up first in file work — see Python file I/O for the file-specific patterns, and try/except basics for a gentler introduction.

Practical Patterns

# Safe conversion with default
def safe_int(value, default=0):
    try:
        return int(value)
    except (ValueError, TypeError):
        return default

age = safe_int(user_input, default=18)

# Retry with exponential backoff
def fetch_with_retry(url, max_retries=3):
    for attempt in range(max_retries):
        try:
            return requests.get(url, timeout=5)
        except requests.RequestException:
            if attempt == max_retries - 1:
                raise
            time.sleep(2 ** attempt)

# Custom context manager guarantees cleanup
from contextlib import contextmanager

@contextmanager
def database_connection(db_name):
    conn = connect_to_db(db_name)
    try:
        yield conn
    finally:
        conn.close()

with database_connection('users') as conn:
    data = conn.query('SELECT * FROM users')

Best Practices

Pro Tip: It's easier to ask for forgiveness than permission (EAFP). In Python, use try-except rather than checking if something exists first. It's more Pythonic and often faster.

← Back to Python Tips