Python Try Except and Context Managers: Error Handling Guide

Catch specific exceptions, keep the original cause with raise from, and let context managers handle cleanup. A practical guide to Python error handling.

Executive Summary: Python’s try and except handle failures, and context managers guarantee cleanup either way. This guide explains why broad handlers hide bugs, how to catch the narrowest exception you can act on, how to keep the original cause with raise from, and why every resource should use with.

A payment job ran every night for three weeks and reported success each time. It had processed nothing. A handler written as except Exception: pass swallowed a KeyError caused by a renamed field, so every record failed silently. The fix took five minutes, and finding it took three weeks.

A Python try except statement runs a block of code and, if that code raises an exception, hands control to a matching handler. An exception is an object that signals an error and carries a message and a traceback. A context manager is an object used with the with statement that runs setup and cleanup code around a block.

This guide uses Python 3.10 and only the standard library. Run python --version to confirm your interpreter. You should be comfortable writing functions and know that a function without a return hands back None.

My position: most error handling code should not exist. Every except clause is a claim that you know what went wrong and what to do about it. When that claim is false, the handler does more damage than the error.

How Python try except works

The full statement has four clauses. Only try and one of except or finally are required.

try:
    risky()            runs first
except ValueError:
    handle()           runs only if the try block raised ValueError
else:
    on_success()       runs only if the try block raised nothing
finally:
    cleanup()          always runs, on success, failure, or return
What happened in try except else finally Afterwards
No exception Skipped Runs Runs Execution continues
A matching exception Runs Skipped Runs Execution continues
A non-matching exception Skipped Skipped Runs Exception propagates to the caller

Here is a small example that uses every clause.

def parse_port(text: str) -> int | None:
    try:
        port = int(text)
    except ValueError:
        print(f'not a number: {text!r}')
        return None
    else:
        print(f'parsed {port}')
        return port
    finally:
        print('done')


parse_port('8080')
parse_port('http')
parsed 8080
done
not a number: 'http'
done

The else clause looks optional, yet it earns its place. Code inside try is protected by the handler, and code in else is not. Consequently, keeping the try block to the one line that can fail prevents the handler from catching an unrelated error by accident.

Catch specific exceptions, not everything

Exceptions form a class hierarchy, and an except clause catches the named class and all its subclasses. The top of the tree matters most.

BaseException
  SystemExit               raised by sys.exit()
  KeyboardInterrupt        raised by Ctrl+C
  Exception                the base for ordinary errors
    ArithmeticError
      ZeroDivisionError
    LookupError
      KeyError
      IndexError
    OSError
      FileNotFoundError
      PermissionError
    ValueError
    TypeError

A bare except: catches BaseException, including KeyboardInterrupt and SystemExit. The result is a program that ignores Ctrl+C.

# WRONG: hides every error, including typos and Ctrl+C
def read_age(record: dict) -> int:
    try:
        return int(record['agee'])
    except:
        return 0
# RIGHT: handles the one failure you expect, and lets bugs surface
def read_age(record: dict) -> int:
    try:
        return int(record['age'])
    except ValueError:
        return 0

In the wrong version, the misspelled key 'agee' raises KeyError on every call, and the function returns 0 forever. In the right version, the same typo crashes on the first run with a traceback that points at the line. A crash during development is cheap, and wrong data in production is not.

To handle several types the same way, use a tuple: except (KeyError, IndexError):. To handle them differently, write several except clauses, ordered from most specific to most general, because Python uses the first match.

Ask forgiveness, not permission

Python code usually attempts the operation and handles failure, a style known as EAFP: “easier to ask forgiveness than permission”. The alternative, checking first, is called LBYL: “look before you leap”.

from pathlib import Path

path = Path('settings.json')

# LBYL: the file can disappear between the check and the read
if path.exists():
    text = path.read_text(encoding='utf-8')

# EAFP: one step, no gap
try:
    text = path.read_text(encoding='utf-8')
except FileNotFoundError:
    text = '{}'

The check-first version has a race condition: another process can delete the file after exists() returns True. In practice, EAFP is the safer choice for anything outside your process, such as files, networks, and databases.

Raise, re-raise, and chain with raise from

You signal an error with raise. Inside a handler, you have three options, and each one tells a different story in the traceback.

Statement Effect Use it when
raise Re-raises the current exception unchanged You logged or cleaned up, and the caller must still see the error
raise NewError(...) from error Raises a new exception and records the original as its cause You translate a low-level error into one your callers understand
raise NewError(...) from None Raises a new exception and hides the original The original is noise or would leak internal details

Chaining matters because it preserves evidence. With from error, the traceback prints both exceptions, joined by this line:

The above exception was the direct cause of the following exception:

If you raise a new exception inside a handler without from, Python still links them, but with a different message: During handling of the above exception, another exception occurred. That wording suggests your handler itself failed. Therefore, write from error whenever the translation is deliberate.

Define custom exceptions

A custom exception is a class that inherits from Exception. It gives callers something precise to catch. If classes are unfamiliar, read about writing a regular class first.

class ConfigError(Exception):
    """Raised when the configuration cannot be used."""


class MissingKeyError(ConfigError):
    """Raised when a required key is absent."""

Give each library or application one base exception and derive the rest from it. Callers can then catch the base to handle anything from your code, or a subclass to handle one case. Keep the hierarchy shallow: three or four classes serve most projects.

Context managers: cleanup you cannot forget

Resources such as files, locks, and database connections must be released, even when an error interrupts your code. You could write try and finally every time. A context manager packages that pattern so you cannot forget it.

# WRONG: if write() raises, the file is never closed
handle = open('report.txt', 'w', encoding='utf-8')
handle.write(build_report())
handle.close()

# RIGHT: the file closes when the block ends, however it ends
with open('report.txt', 'w', encoding='utf-8') as handle:
    handle.write(build_report())

The with statement calls the object’s __enter__ method at the start and its __exit__ method at the end. Python calls __exit__ on normal completion, on an exception, and on return.

Write your own with contextlib

The simplest way to build a context manager is the @contextmanager decorator. You write a generator function with a single yield. Code before the yield is setup, and code after it is cleanup.

import os
from contextlib import contextmanager
from pathlib import Path


@contextmanager
def working_directory(path: Path):
    previous = Path.cwd()
    os.chdir(path)
    try:
        yield
    finally:
        os.chdir(previous)

The try and finally around yield are essential. Without them, an exception inside the with block skips the cleanup, which defeats the purpose.

The contextlib module includes other ready-made helpers. suppress replaces a try block whose handler is only pass, and it states the intent openly:

from contextlib import suppress
from pathlib import Path

with suppress(FileNotFoundError):
    Path('cache.tmp').unlink()

For a variable number of resources, contextlib.ExitStack lets you register context managers in a loop and closes them all in reverse order.

A complete example: load a config file

This script combines a custom exception, raise ... from, a context manager, and a single handler at the top level. Save it as load_config.py.

import json
import sys
import time
from contextlib import contextmanager
from pathlib import Path


class ConfigError(Exception):
    """Raised when the configuration file cannot be used."""


@contextmanager
def timed(label: str):
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f'{label} took {elapsed:.4f}s')


def load_config(path: Path) -> dict:
    try:
        text = path.read_text(encoding='utf-8')
    except FileNotFoundError as error:
        raise ConfigError(f'config file not found: {path}') from error
    try:
        config = json.loads(text)
    except json.JSONDecodeError as error:
        raise ConfigError(f'invalid JSON in {path}: {error}') from error
    if 'port' not in config:
        raise ConfigError(f"missing required key 'port' in {path}")
    return config


def main() -> int:
    path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('config.json')
    try:
        with timed('load'):
            config = load_config(path)
    except ConfigError as error:
        print(f'error: {error}', file=sys.stderr)
        return 1
    print(f"listening on port {config['port']}")
    return 0


if __name__ == '__main__':
    raise SystemExit(main())

Run it in a folder without a config file:

python load_config.py
load took 0.0001s
error: config file not found: config.json

Now create config.json containing {"port": 8080} and run it again:

load took 0.0002s
listening on port 8080

Notice the structure. load_config translates two low-level errors into one domain error, and it lets everything else propagate. Only main decides what the user sees and which exit code the process returns. The timer prints in both runs, because its cleanup sits in finally.

The myth: try blocks are slow

A common belief says that exceptions are expensive, so you should check conditions first. The truth has two halves. Entering a try block costs almost nothing. Raising and catching an exception costs noticeably more than an if.

You can measure both halves. These are my own illustrative tests, and your numbers will differ. The first pair looks up a key that exists:

python -m timeit -s "d = {'a': 1}" "try:" "    v = d['a']" "except KeyError:" "    v = 0"
python -m timeit -s "d = {'a': 1}" "v = d['a'] if 'a' in d else 0"

The second pair looks up a key that is missing:

python -m timeit -s "d = {'a': 1}" "try:" "    v = d['b']" "except KeyError:" "    v = 0"
python -m timeit -s "d = {'a': 1}" "v = d['b'] if 'b' in d else 0"

On my machine, the try version wins when the key exists, because it does one lookup instead of two. It loses by a wide margin when the key is missing, because building and unwinding an exception is real work.

The rule that follows: use try when failure is rare, and a check (or dict.get) when “missing” is a normal, frequent outcome. Either way, the difference is a fraction of a microsecond, so correctness and clarity should decide first.

Where to catch: the three legitimate reasons

Here is the heuristic I use in code review. A handler is justified only if it does one of three things.

  1. Recover. You can produce a correct result anyway: retry, use a default, or skip one record.
  2. Translate. You convert a low-level error into one that means something to your caller, with raise ... from.
  3. Record and re-raise. You add context to a log, then use a bare raise so the error continues.

If a handler does none of these, delete it. An exception that travels up to a single top-level handler produces one clear log entry. The same exception caught and logged at four levels produces four entries and four chances to lose the traceback.

The finally trap

One non-obvious behavior deserves a warning. A return inside finally silently discards any exception in flight.

def risky() -> str:
    try:
        raise ValueError('lost forever')
    finally:
        return 'ok'


print(risky())      # prints ok, and the ValueError vanishes

The function returns normally, and no traceback appears. Never use return, break, or continue inside a finally block. If you need different return values and None handling, put the return after the whole statement.

How real systems handle errors

  • One boundary handler per entry point. A web framework catches unhandled exceptions per request, logs the traceback, and returns a 500 response. A command-line tool does the same in main() and returns a non-zero exit code.
  • Domain exceptions at layer edges. A data access layer converts driver errors into its own exception types. Callers never import the database driver just to catch its errors.
  • Per-item isolation in batch jobs. A loop over 10,000 records catches the expected failure for one record, logs it with the record ID, counts it, and continues. Afterwards, the job fails loudly if the error rate crosses a threshold.
  • Context managers for every resource. Files, locks, transactions, and temporary directories all use with. Code review treats a manual close() call as a defect.
  • Logging with the traceback. Inside a handler, logging.exception('message') records the full traceback. A plain print(error) records one line and discards the rest.

We once hit a bug when a retry wrapper caught Exception around a call that sent emails. A TypeError from a bad template argument was retried five times with backoff, for every message in the queue. The queue backed up for hours, and the real error appeared only at debug log level. Narrowing the handler to connection and timeout errors made the template bug fail on the first message.

Handling an error: a decision framework

When a line of code can fail, ask these questions in order.

  1. Is the failure a bug in your code? For TypeError, AttributeError, or NameError, do not catch it. Fix the code.
  2. Can this function recover correctly right here? If so, catch the specific exception and recover.
  3. Does the caller need a clearer error? Catch the low-level exception and raise a domain exception with from error.
  4. Must something be released either way? Use a with statement, or finally if no context manager exists.
  5. None of the above? Write no handler, and let the exception reach the boundary.

When NOT to catch an exception

  • When it signals a programming error. Catching TypeError or AttributeError to “be safe” turns a visible crash into silent wrong behavior.
  • When you have nothing useful to do. A handler that only prints and continues leaves the program in an unknown state. Later code then fails somewhere unrelated.
  • When absence is a normal answer. For a lookup that often misses, dict.get(key) or a function that returns None is clearer and cheaper than raising and catching on every call.

Common mistakes

  • Writing a bare except. It catches KeyboardInterrupt and SystemExit, so the program cannot be stopped cleanly. Use except Exception at most, and only at a boundary.
  • Swallowing with pass. except Exception: pass erases the only evidence of a failure. Jobs report success while doing nothing.
  • Wrapping too much code in one try. A twenty-line block can raise the same exception type from five places. The handler then treats four unexpected failures like the one you planned for.
  • Raising a new exception without from. Writing raise ConfigError(str(error)) flattens the original to a string. You lose its type and its traceback location.
  • Returning inside finally. The return value replaces any exception in flight. Errors disappear without a trace.
  • Closing resources by hand. A close() call after the work is skipped when the work raises. File handles and connections leak until the process hits its limit.

Key takeaways

  • Catch the most specific exception that you can actually handle.
  • Keep the try block to the line that can fail, and put follow-up code in else.
  • Translate low-level errors with raise NewError(...) from error to keep the cause.
  • Use with for every file, lock, and connection.
  • Build simple context managers with @contextmanager, and wrap the yield in try and finally.
  • A handler must recover, translate, or record and re-raise. Otherwise, remove it.
  • Handle unexpected errors once, at the boundary of the program.

FAQ

How does try except work in Python?

Python runs the try block. If it raises an exception that matches an except clause, that handler runs. The optional else runs when nothing was raised, and finally always runs.

What is the difference between except and finally?

except runs only when a matching exception occurs, and it handles the error. finally runs every time, with or without an error, and it is meant for cleanup.

Why is a bare except bad in Python?

A bare except: catches everything, including KeyboardInterrupt and SystemExit. It hides bugs such as typos and makes the program hard to stop.

What does raise from do in Python?

raise NewError() from original raises a new exception and stores the original as its cause. The traceback then shows both, so you keep the root cause while giving callers a clearer error.

What is a context manager in Python?

It is an object used with the with statement that runs setup code on entry and cleanup code on exit. The cleanup runs even if the block raises an exception.

Catch less, clean up always

Good error handling is mostly restraint. Let unexpected failures crash loudly at one boundary, translate the few errors your callers care about, and hand cleanup to with. The code gets shorter, and the failures get easier to find.

Rule of thumb: if you cannot say what the handler fixes, the handler is the bug.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *