Skip to main content

Error Handling

Handle errors and exceptions in Python

Error handling in Python uses the try/except/else/finally block to catch exceptions instead of returning error codes. The language pushes you toward EAFP (easier to ask forgiveness than permission): try the operation, catch the specific exception if it fails. That idiom replaces the LBYL (look before you leap) pattern of checking preconditions, which races on the file system and on shared state. Read the next 6 sections and the same exception code that confused you in CS50P or 6.100L becomes mechanical.

The four-clause anatomy of try/except/else/finally

A full exception block has four parts, used in this exact order. try wraps the risky code. except runs only if a matching exception fires inside try. else runs only if try completed without an exception. finally runs always, exception or not, and is the place to release resources.

The else clause is the part most students skip and most TAs reward. Code that should only run after a successful try belongs in else, not at the bottom of try. Putting it in try means a later exception (raised by the success path) gets caught by the same except, which masks the real bug. Putting it in else makes the boundary explicit: try contains exactly the line that may raise, else contains everything that runs only on success.

finally always runs. Use it for cleanup that must happen regardless of outcome: closing a network connection, restoring a working directory, releasing a lock. Context managers (with statements) cover the most common cases automatically, but finally is the explicit form when no context manager exists.

Example

                      
                        def divide_safely(numerator, denominator):
    try:
        result = numerator / denominator  # the risky operation
    except ZeroDivisionError:  # only runs on this specific failure
        print('Cannot divide by zero')
        return None
    except TypeError:  # different failure, different handling
        print('Both arguments must be numbers')
        return None
    else:  # only runs if try succeeded with no exception
        print(f'Division succeeded: {result}')
        return result
    finally:  # always runs, success or failure
        print('divide_safely() finished')

# Three calls covering all three paths
divide_safely(10, 2)   # success path: else and finally both run
divide_safely(10, 0)   # ZeroDivisionError path
divide_safely(10, 'x') # TypeError path
                      
                    

The exception hierarchy and why specificity matters

Every exception in Python is a subclass of BaseException, with most user-facing exceptions descending from Exception. ValueError, TypeError, KeyError, IndexError, FileNotFoundError, and ZeroDivisionError are siblings; LookupError is the parent of both KeyError and IndexError; OSError is the parent of FileNotFoundError and PermissionError.

An except clause catches the named class and every subclass. except LookupError: catches both KeyError and IndexError. except Exception: catches almost everything (but not KeyboardInterrupt or SystemExit, which descend from BaseException directly). except BaseException: catches even Ctrl-C, which is almost always a bug.

Order matters: Python checks except clauses top to bottom and runs the first match. Put specific exceptions first, broad ones last. except Exception: at the top of a chain swallows every later clause and hides the real problem.

Example

                      
                        data = {'name': 'Alice', 'scores': [92, 88, 95]}

try:
    third_score = data['scores'][2]  # could raise KeyError or IndexError
    print(f'Third score: {third_score}')
except KeyError as e:  # missing dict key, specific case
    print(f'Missing key: {e}')
except IndexError as e:  # list index out of range, specific case
    print(f'Bad index: {e}')
except LookupError as e:  # parent of both, catches anything else
    print(f'Lookup failed: {e}')
except Exception as e:  # catch-all last, never first
    print(f'Unexpected error: {type(e).__name__}: {e}')
                      
                    

Catching specific exceptions beats bare except every time

A bare except: or except Exception: catches everything, including the typo in your variable name that should have crashed loudly. The bug stays silent, your tests pass, the grader sees wrong output, and you spend 2 hours hunting a NameError that was never visible.

Catch the exact exception class you expect. If you are reading a file, catch FileNotFoundError and PermissionError, not OSError. If you are parsing user input, catch ValueError, not Exception. The more specific the catch, the easier the debugging.

The one acceptable use of broad except: is at the top level of a long-running program (a web server, a daemon, a worker queue) where any unhandled exception terminates the process. Even there, log the full traceback with traceback.format_exc() before swallowing the exception. Never write except: pass without a logging line, that pattern hides every bug your code ever produces.

Example

                      
                        import traceback  # for full traceback printing

def parse_grade(raw):
    # Convert raw string to a grade integer, return None on bad input
    try:
        return int(raw)  # may raise ValueError
    except ValueError:  # specific, catches '92.5' and 'A' but not NameError
        return None

print(parse_grade('92'))    # 92
print(parse_grade('A'))     # None
print(parse_grade('92.5'))  # None

# Acceptable broad except, with logging
def run_one_task():
    raise ValueError('something specific went wrong')

try:
    run_one_task()
except Exception:  # broad, but only at the outer boundary
    print('Task failed, full traceback:')
    traceback.print_exc()  # full traceback, never silently swallowed
                      
                    

Raising exceptions and re-raising with raise

raise creates and throws an exception. raise ValueError('Score must be 0 to 100') raises a new ValueError with a message. The bare keyword raise inside an except block re-raises the current exception, preserving the original traceback. That is the right move when you want to log or partially handle an exception and let it propagate up.

The raise X from Y form chains exceptions: the new exception's __cause__ points at the original. Use it when you wrap a low-level exception in a higher-level one. raise InvalidGradeError('Bad input') from e tells the reader that an underlying error caused the new one, and Python prints both tracebacks.

When designing functions, raise on the boundary. A parse_grade() that returns None on bad input pushes the error check to every caller. A parse_grade() that raises ValueError on bad input lets one try block at the outer layer catch every parsing failure. Raise early, catch late.

Example

                      
                        def validate_score(score):
    # Raise a clear exception if the score is out of range
    if not isinstance(score, (int, float)):
        raise TypeError(f'Score must be a number, got {type(score).__name__}')
    if score < 0 or score > 100:
        raise ValueError(f'Score must be 0 to 100, got {score}')
    return score  # validated, ready to use

try:
    validate_score(150)
except ValueError as e:
    print(f'Validation failed: {e}')

# Chained exceptions with raise from
def load_config(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError as e:
        raise RuntimeError(f'Config required at {path}') from e  # chain preserves cause

try:
    load_config('missing.ini')
except RuntimeError as e:
    print(f'Higher-level error: {e}')
    print(f'Caused by: {e.__cause__}')  # the original FileNotFoundError
                      
                    

Custom exceptions for assignment-specific error types

Subclass Exception to create your own exception type. class InvalidSubmissionError(Exception): pass is the full definition for a basic custom exception. Every caller can then catch InvalidSubmissionError without also catching unrelated ValueErrors that happen to mention 'invalid'.

Custom exceptions read well in tests and grading rubrics. with pytest.raises(InvalidSubmissionError): submit(bad_input) tells the reader exactly which failure the test asserts. The same test with with pytest.raises(ValueError): would pass even if the function raised a ValueError for the wrong reason.

For coursework that defines a domain (a banking app, an inventory system, a graph library), add a small hierarchy: a base class like AppError, and child classes like InvalidInputError, NotFoundError, PermissionDeniedError. Each child carries its own message format and any extra attributes the caller needs.

Example

                      
                        class GradeError(Exception):
    # Base class for any grading-related failure
    pass

class OutOfRangeError(GradeError):
    # Score is outside 0-100
    def __init__(self, score):
        self.score = score  # attach the bad value for later inspection
        super().__init__(f'Score {score} is outside 0-100')

class MissingStudentError(GradeError):
    # No student record matches the given ID
    def __init__(self, student_id):
        self.student_id = student_id
        super().__init__(f'No student with id {student_id}')

def record_grade(student_id, score):
    roster = {101: 'Alice', 102: 'Bob'}
    if student_id not in roster:
        raise MissingStudentError(student_id)
    if score < 0 or score > 100:
        raise OutOfRangeError(score)
    print(f'Recorded {score} for {roster[student_id]}')

try:
    record_grade(999, 88)
except GradeError as e:  # one catch handles both child classes
    print(f'Grading failed: {e}')
                      
                    

EAFP versus LBYL, and why Python prefers EAFP

LBYL (look before you leap) checks preconditions before acting: if key in d: value = d[key]. EAFP (easier to ask forgiveness than permission) acts first and catches the exception: try: value = d[key] except KeyError: ....

Python prefers EAFP for three reasons. First, race conditions: between the if os.path.exists(path) check and the open(path) call, another process can delete the file. The check passes, the open fails, the program crashes. The try/except pattern has no gap. Second, performance: the common case (key is present, file exists, value is valid) costs one operation in EAFP and two in LBYL. Third, expressiveness: try blocks compose naturally with the result, while LBYL spreads guard clauses across the function.

Use LBYL when the precondition is cheap and the failure is expected often (form validation in a web handler, where 30% of inputs are invalid). Use EAFP when the failure is rare or when a race condition can occur (file system, network, shared state, dict and list access).

Example

                      
                        import os  # standard library

# LBYL: check first, then act
config = {'host': 'localhost', 'port': 5432}
key = 'host'
if key in config:  # check
    host = config[key]  # act
    print(f'LBYL host: {host}')

# EAFP: try the action, catch the failure
try:
    host = config['host']  # one operation, no precheck
except KeyError:
    host = 'localhost'  # fallback
print(f'EAFP host: {host}')

# Race-free file read: only EAFP works correctly
def read_or_default(path, default=''):
    try:
        with open(path, 'r', encoding='utf-8') as f:
            return f.read()
    except FileNotFoundError:
        return default  # path may have existed at check time, gone by open time

print(f'Notes: {read_or_default("notes.txt", "none")!r}')
                      
                    

Common pitfalls

Using a bare except: hides typos and NameErrors, so the program prints wrong output instead of crashing visibly.

Catch the specific exception class you expect (ValueError, KeyError, FileNotFoundError). Never write except: alone.

Putting success-path code inside the try block means later exceptions get caught by the wrong handler.

Move post-success code into an else clause. The try block should hold only the line that may raise.

Ordering except clauses with Exception first makes every later clause unreachable.

Order from most specific to least specific. Python matches top to bottom and runs the first match only.

Raising a generic Exception("oops") makes callers catch Exception and accidentally swallow unrelated bugs.

Subclass Exception to create a domain-specific class like InvalidScoreError, then raise that instead.

Calling raise e inside an except block resets the traceback and hides where the original error came from.

Use bare raise to re-raise with the original traceback, or raise NewError(...) from e to chain causes.

Wrapping every dict access in if key in d: turns 10-line functions into 40-line guard mazes.

Use d.get(key, default) for optional values or try/except KeyError for the rare missing-key case (EAFP).

When to use error handling

Use try/except whenever an operation depends on external state that may fail at runtime: file I/O, network requests, user input parsing, dict and list access on unknown data. Skip exception handling for purely internal logic where a failure means your own code is wrong and should crash loudly.

Need Help?

Having trouble with this topic on an assignment? Our Python developers ship working code plus a walkthrough that helps you explain the code in class.